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

"""Mapping database models to REST Api support."""

import datetime as dt
from collections.abc import Callable
from copy import copy
from typing import (
    Any,
    NamedTuple,
    Never,
    ParamSpec,
    TYPE_CHECKING,
    TypeAlias,
    TypeVar,
)

import pydantic
from django.conf import settings
from django.db.models import QuerySet
from django.http import HttpRequest
from django.urls import reverse
from rest_framework import serializers
from rest_framework.exceptions import ErrorDetail, ValidationError

from debusine.artifacts.models import (
    ArtifactCategory,
    CollectionCategory,
    DebusineTaskConfiguration,
)
from debusine.assets import AssetCategory, asset_data_model
from debusine.client.models import (
    Link,
    LookupChildType,
    LookupResultType,
    WorkspaceInheritanceChainElement,
)
from debusine.db.context import context
from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    Asset,
    Collection,
    CollectionItem,
    File,
    Token,
    WorkRequest,
    WorkflowTemplate,
    Workspace,
)
from debusine.db.models.collections import (
    CollectionRelation,
    validate_collection_name,
)
from debusine.db.models.lookups import LookupResult
from debusine.server.exceptions import (
    DebusineAPIException,
    raise_workspace_not_found,
)
from debusine.server.workflows import Workflow
from debusine.server.workflows.models import WorkRequestManualUnblockAction
from debusine.tasks.models import EventReactions, OutputData, WorkerType

if TYPE_CHECKING:
    BaseSerializerBase: TypeAlias = serializers.BaseSerializer
    SerializerMixinBase: TypeAlias = serializers.Serializer
    SlugRelatedFieldBase = serializers.SlugRelatedField
    FieldBase = serializers.Field
else:
    # BaseSerializer doesn't support generic types at run-time yet.
    class _BaseSerializerBase:
        def __class_getitem__(
            cls,
            *args: TypeVar | ParamSpec | tuple[TypeVar | ParamSpec, ...],
        ) -> type[serializers.BaseSerializer]:
            return serializers.BaseSerializer

    BaseSerializerBase = _BaseSerializerBase

    class _SerializerMixinBase:
        def __class_getitem__(
            cls,
            *args: TypeVar | ParamSpec | tuple[TypeVar | ParamSpec, ...],
        ) -> type[serializers.Serializer]:
            return serializers.Serializer

    SerializerMixinBase = _SerializerMixinBase

    # SlugRelatedField doesn't support generic types at run-time yet.
    class _SlugRelatedFieldBase:
        def __class_getitem__(
            cls,
            *args: TypeVar | ParamSpec | tuple[TypeVar | ParamSpec, ...],
        ) -> type[serializers.SlugRelatedField]:
            return serializers.SlugRelatedField

    SlugRelatedFieldBase = _SlugRelatedFieldBase

    # Field doesn't support generic types at run-time yet.
    class _FieldBase:
        def __class_getitem__(
            cls,
            *args: TypeVar | ParamSpec | tuple[TypeVar | ParamSpec, ...],
        ) -> type[serializers.Field]:
            return serializers.Field

    FieldBase = _FieldBase


class DebusineSerializerMixin[T](SerializerMixinBase[T]):
    """Adjust a serializer with debusine-specific behaviour."""

    validation_error_title: str

    def is_valid(self, *, raise_exception: bool = False) -> bool:
        """Raise a suitable API exception when deserialization fails."""
        valid = super().is_valid()
        if raise_exception and not valid:
            if (workspace_errors := self.errors.get("workspace", [])) and all(
                isinstance(error, ErrorDetail)
                and error.code == "does_not_exist"
                for error in workspace_errors
            ):
                raise_workspace_not_found(self.data["workspace"])
            else:
                raise DebusineAPIException(
                    title=self.validation_error_title,
                    validation_errors=self.errors,
                )
        return valid


class WorkspaceInCurrentScopeField(SlugRelatedFieldBase[Workspace]):
    """A field for workspaces in the current scope."""

    def __init__(self, **kwargs: Any) -> None:
        """Construct the field."""
        kwargs.setdefault("default", self._get_default_workspace)
        super().__init__(**kwargs)

    def get_queryset(self) -> QuerySet[Workspace]:
        """Limit to the current scope."""
        return Workspace.objects.in_current_scope()

    def _get_default_workspace(self) -> Workspace:
        """Get the default workspace."""
        try:
            return self.get_queryset().get(
                name=settings.DEBUSINE_DEFAULT_WORKSPACE
            )
        except Workspace.DoesNotExist:
            # The default workspace name might not exist in the current
            # scope.  If so, the client must provide a workspace.
            self.fail("required")


class LinksSerializerMixin[T](SerializerMixinBase[T]):
    """Add a links record to a serializer."""

    def get_links(self, instance: T) -> dict[str, Link]:  # noqa: ARG002, U100
        """Return links for this object."""
        return {}

    def to_representation(self, instance: T) -> dict[str, Any]:
        """Add links to the serialized representation."""
        links: dict[str, str | dict[str, str]] = {}
        for name, link in self.get_links(instance).items():
            if link.can_be_str():
                links[name] = link.href
            else:
                links[name] = link.model_dump(exclude_unset=True)
        res = super().to_representation(instance)
        res["links"] = links
        return res


class WorkRequestSerializer(
    LinksSerializerMixin[WorkRequest],
    DebusineSerializerMixin[WorkRequest],
    serializers.ModelSerializer[WorkRequest],
):
    """Serializer of a WorkRequest."""

    validation_error_title = "Cannot deserialize work request"

    url = serializers.URLField(source="get_absolute_url", read_only=True)
    artifacts = serializers.SerializerMethodField("artifacts_for_work_request")
    scope = serializers.CharField(source="workspace.scope.name", read_only=True)
    workspace = WorkspaceInCurrentScopeField(slug_field="name")
    workflow_data = serializers.JSONField(
        source="workflow_data_json", required=False
    )
    event_reactions = serializers.JSONField(
        source='event_reactions_json', required=False
    )
    scheduler_tags_provided = serializers.ListField(
        child=serializers.CharField(), read_only=True
    )
    scheduler_tags_required = serializers.ListField(
        child=serializers.CharField(), read_only=True
    )

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """
        Initialize object.

        :param kwargs['only_fields']: if not None: any field in .data that does
          not will make validate() raise a ValidationError.
        """
        self._only_fields = kwargs.pop('only_fields', None)
        super().__init__(*args, **kwargs)

    def get_links(self, instance: WorkRequest) -> dict[str, Link]:
        """Get links to add to the serialized record."""
        res = super().get_links(instance)
        for link, work_request in (
            ("self", instance),
            ("parent", instance.parent),
            ("workflow_root", instance.workflow_root),
        ):
            if work_request is not None:
                res[link] = Link(
                    href=reverse(
                        "api:work-request-detail",
                        kwargs={"work_request_id": work_request.pk},
                    )
                )
                res[f"webui_{link}"] = Link(
                    href=work_request.get_absolute_url()
                )
        for link, user in (
            ("created_by", instance.created_by),
            ("aborted_by", instance.aborted_by),
        ):
            if user is not None:
                res[f"webui_{link}"] = Link(
                    href=user.get_absolute_user_id_url()
                )
        return res

    def artifacts_for_work_request(
        self, work_request: WorkRequest
    ) -> list[int]:
        """Return list of artifact ids created by this work request."""
        return list(
            work_request.artifact_set.order_by("id").values_list(
                "id", flat=True
            )
        )

    def validate(self, data: Any) -> Any:
        """
        Add extra validation in the WorkRequestSerializer. Used by .is_valid().

        -if only_fields was passed in the __init__: raise ValidationError
          if the initial data had an unexpected field.
        """
        if self._only_fields is not None:
            fields_in_data = set(self.initial_data.keys())
            wanted_fields = set(self._only_fields)

            if unwanted_fields := (fields_in_data - wanted_fields):
                raise serializers.ValidationError(
                    f"Invalid fields: {', '.join(sorted(unwanted_fields))}"
                )

        return data

    def validate_event_reactions(self, value: Any) -> Any:
        """Validate event reactions."""
        try:
            EventReactions.model_validate(value)
        except pydantic.ValidationError as e:
            raise serializers.ValidationError(f"Invalid event_reactions: {e}")
        return value

    class Meta:
        model = WorkRequest
        fields = [
            'id',
            'url',
            'parent',
            'workflow_root',
            'task_name',
            'created_at',
            'created_by',
            'completed_by',
            'aborted_by',
            'started_at',
            'completed_at',
            'duration',
            'worker',
            'task_type',
            'task_data',
            'dynamic_task_data',
            'priority_base',
            'priority_adjustment',
            'workflow_data',
            'event_reactions',
            'status',
            'result',
            'artifacts',
            'scope',
            'workspace',
            'scheduler_tags_provided',
            'scheduler_tags_required',
        ]


class WorkRequestSerializerWithConfiguredTaskData(WorkRequestSerializer):
    """Specialization of WorkRequestSerializer to feed task data to workers."""

    def to_representation(self, instance: WorkRequest) -> dict[str, Any]:
        """
        Use configured_task_data instead of task_data.

        This is intended to feed configured_task_data to workers without
        breaking the worker API.
        """
        res = super().to_representation(instance)
        if instance.configured_task_data:
            res["task_data"] = instance.configured_task_data
        return res


class WorkerWorkRequestSerializer(WorkRequestSerializerWithConfiguredTaskData):
    """Specialization of WorkRequestSerializer including a limited token."""

    token = serializers.SerializerMethodField()

    def get_token(self, instance: WorkRequest) -> str:
        """Issue a limited token and return its key."""
        return Token.objects.create_work_request_token(instance).key

    class Meta:
        model = WorkRequest
        fields = [
            *WorkRequestSerializerWithConfiguredTaskData.Meta.fields,
            "token",
        ]
        read_only_fields = fields


class WorkRequestUpdateSerializer(WorkRequestSerializer):
    """Serializer for updating work requests."""

    validation_error_title = "Cannot deserialize work request update"

    class Meta:
        model = WorkRequest
        fields = ['priority_adjustment']

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Ensure that only expected fields are passed."""
        kwargs["only_fields"] = self.Meta.fields
        super().__init__(*args, **kwargs)


class WorkRequestUnblockSerializer(
    DebusineSerializerMixin[WorkRequest], serializers.Serializer[WorkRequest]
):
    """Serializer for the data used to edit a manual unblock."""

    validation_error_title = "Cannot deserialize work request unblock"

    notes = serializers.CharField(max_length=65536, required=False)
    action = serializers.ChoiceField(
        choices=list(WorkRequestManualUnblockAction), required=False
    )

    def validate(self, data: Any) -> Any:
        """Ensure at least one of notes and action is present."""
        if not data.get("notes") and data.get("action") is None:
            raise serializers.ValidationError(
                "At least one of notes and action must be set"
            )

        return data


class WorkRequestConfirmSerializer(
    DebusineSerializerMixin[WorkRequest], serializers.Serializer[WorkRequest]
):
    """Serializer for the data used to confirm a work request."""

    validation_error_title = "Cannot deserialize work request confirmation"

    confirm = serializers.BooleanField(required=False, default=True)
    comment = serializers.CharField(required=False, max_length=65536)


class WorkRequestExternalDebsignSerializer(
    WorkRequestSerializerWithConfiguredTaskData
):
    """
    Serializer for an `ExternalDebsign` work request.

    This is similar to normal work request serialization, except that we
    make sure to return up-to-date dynamic task data so that it can be used
    to get the unsigned artifact ID.
    """

    def to_representation(self, instance: WorkRequest) -> dict[str, Any]:
        """Augment the representation with current dynamic task data."""
        data = super().to_representation(instance)

        try:
            task = instance.get_task()
            dynamic_task_data = task.compute_dynamic_data()
            data["dynamic_task_data"] = (
                None
                if dynamic_task_data is None
                else dynamic_task_data.model_dump(
                    mode="json", exclude_unset=True
                )
            )
        except Exception as exc:
            raise ValidationError(detail=f"Cannot process task data: {exc}")

        return data


class WorkRequestExternalDebsignRequestSerializer(
    DebusineSerializerMixin[Never], serializers.Serializer[Never]
):
    """Serializer for the data used to complete an `ExternalDebsign` task."""

    validation_error_title = "Cannot deserialize work request"

    signed_artifact = serializers.PrimaryKeyRelatedField(
        queryset=Artifact.objects
    )

    def validate_signed_artifact(self, value: Any) -> Any:
        """Check that the signed artifact has the correct category."""
        if value.category != ArtifactCategory.UPLOAD:
            raise ValidationError(
                detail=(
                    f"Expected signed artifact of category "
                    f"{ArtifactCategory.UPLOAD}; got {value.category}"
                )
            )
        if value.created_by_work_request is not None:
            raise ValidationError(
                detail=(
                    "Signed artifact must not have been created by a work "
                    "request"
                )
            )
        return value


class WorkerRegisterSerializer(serializers.Serializer[Never]):
    """Serializer for the data when a worker is registering."""

    token = serializers.CharField(max_length=64)
    fqdn = serializers.CharField(max_length=400)
    worker_type = serializers.ChoiceField(
        choices=list(WorkerType), default=WorkerType.EXTERNAL
    )


class WorkRequestCompletedSerializer(serializers.ModelSerializer[WorkRequest]):
    """Serializer for the data when a work request is completed."""

    result = serializers.ChoiceField(
        choices=[
            choice
            for choice in WorkRequest.Results
            if choice != WorkRequest.Results.NONE
        ]
    )
    output_data = serializers.JSONField(required=False)

    def validate_output_data(self, value: Any) -> Any:
        """Validate output_data."""
        try:
            OutputData.model_validate(value)
        except pydantic.ValidationError as e:
            raise serializers.ValidationError(f"Invalid output_data: {e}")
        return value

    class Meta:
        model = WorkRequest
        fields = ["result", "output_data"]


class OnWorkRequestCompleted(serializers.Serializer[Never]):
    """Serializer used by the OnWorkRequestCompletedConsumer."""

    type = serializers.CharField()
    text = serializers.CharField()
    work_request_id = serializers.IntegerField()
    completed_at = serializers.DateTimeField()
    result = serializers.ChoiceField(choices=list(WorkRequest.Results))


class FileSerializer(serializers.Serializer[File]):
    """Serializer for the File class."""

    size = serializers.IntegerField(min_value=0)
    checksums = serializers.DictField(
        child=serializers.CharField(max_length=255)
    )
    type = serializers.ChoiceField(choices=("file",))
    content_type = serializers.CharField(allow_null=True, required=False)


class FileSerializerResponse(FileSerializer):
    """Serializer for the File responses. Include URL."""

    url = serializers.URLField()


class ArtifactSerializer(
    DebusineSerializerMixin[Artifact], serializers.Serializer[Artifact]
):
    """Serializer to deserialize the client request to create an artifact."""

    validation_error_title = "Cannot deserialize artifact"

    category = serializers.CharField(max_length=255)
    workspace = WorkspaceInCurrentScopeField(slug_field="name")
    files = serializers.DictField(child=FileSerializer())
    # Deliberately incompatible with Serializer.data.
    data = serializers.DictField()  # type: ignore[assignment]
    work_request = serializers.IntegerField(allow_null=True, required=False)
    expiration_delay = serializers.DurationField(
        allow_null=True, required=False
    )


class ArtifactSerializerResponse(
    LinksSerializerMixin[Artifact], serializers.ModelSerializer[Artifact]
):
    """Serializer for returning the information of an Artifact."""

    url = serializers.URLField(source="get_absolute_url", read_only=True)

    # Artifact's scope and workspace
    scope = serializers.CharField(source="workspace.scope.name", read_only=True)
    workspace = serializers.CharField(source="workspace.name", read_only=True)

    # Files with their hash and size
    files = serializers.SerializerMethodField("_files")

    # List of file paths pending to be uploaded
    files_to_upload = serializers.SerializerMethodField("_files_to_upload")

    # URL to download the artifact
    download_tar_gz_url = serializers.SerializerMethodField(
        "_download_tar_gz_url"
    )

    _base_download_url: str

    class Meta:
        model = Artifact
        fields = [
            "id",
            "url",
            "scope",
            "workspace",
            "category",
            "data",
            "created_at",
            "expire_at",
            "download_tar_gz_url",
            "files_to_upload",
            "files",
        ]

    def get_links(self, instance: Artifact) -> dict[str, Link]:
        """Get links to add to the serialized record."""
        res = super().get_links(instance)
        res["self"] = Link(
            href=reverse(
                "api:artifact",
                kwargs={"artifact_id": instance.pk},
            )
        )
        res["webui_self"] = Link(href=instance.get_absolute_url())
        res["download_tar_gz"] = Link(
            href=instance.get_absolute_url_download() + "?archive=tar.gz",
            type="application/tar+gzip",
        )
        return res

    def _files_to_upload(self, artifact: Artifact) -> list[str]:
        """Return file paths from artifact that have not been uploaded yet."""
        return list(
            artifact.fileinartifact_set.filter(complete=False)
            .order_by("path")
            .values_list("path", flat=True)
        )

    def _files(self, artifact: Artifact) -> dict[str, dict[str, Any]]:
        """
        Return files in the artifact with their hash and size.

        The files might not be available yet if they have not been uploaded.
        """
        files: dict[str, dict[str, Any]] = {}

        for file_in_artifact in artifact.fileinartifact_set.order_by("path"):
            file = file_in_artifact.file
            data = {
                "size": file.size,
                "type": "file",
                "checksums": {
                    file.current_hash_algorithm: file.hash_digest.hex()
                },
                "url": self._base_download_url + file_in_artifact.path,
                "content_type": file_in_artifact.content_type,
            }
            file_serialized = FileSerializerResponse(data=data)
            file_serialized.is_valid(raise_exception=True)

            files[file_in_artifact.path] = file_serialized.data

        return files

    def _download_tar_gz_url(self, obj: Artifact) -> str:  # noqa: ARG002, U100
        """Based on self._base_download_url return URL to download .tar.gz."""
        return self._base_download_url + "?archive=tar.gz"

    @staticmethod
    def _build_absolute_download_url(
        artifact: Artifact, request: HttpRequest
    ) -> str:
        """Return URL to download the artifact (without the archive format)."""
        return request.build_absolute_uri(artifact.get_absolute_url_download())

    @classmethod
    def from_artifact(
        cls, artifact: Artifact, request: HttpRequest
    ) -> "ArtifactSerializerResponse":
        """Given an artifact and request return ArtifactSerializerResponse."""
        serialized = ArtifactSerializerResponse(artifact)
        serialized._base_download_url = cls._build_absolute_download_url(
            artifact, request
        )
        return serialized


class ArtifactRelationResponseSerializer(
    DebusineSerializerMixin[ArtifactRelation],
    serializers.ModelSerializer[ArtifactRelation],
):
    """Serializer for relations between artifacts."""

    validation_error_title = "Cannot deserialize artifact relation"

    media_type = "application/json"

    class Meta:
        model = ArtifactRelation
        fields = ["id", "artifact", "target", "type"]
        # Don't let REST framework validate uniqueness constraints (which it
        # does automatically as of 3.15.0).  We want GetOrCreateAPIView to
        # return 200 if the relation already exists, which it will do if
        # REST framework hasn't returned 400 first due to a validation
        # failure.
        validators: list[Callable[[ArtifactRelation], None]] = []


class AssetSerializer(
    DebusineSerializerMixin[Asset], serializers.Serializer[Asset]
):
    """Serializer to deserialize the client request to create an asset."""

    validation_error_title = "Cannot deserialize asset"

    id = serializers.IntegerField(required=False, read_only=True)
    created_at = serializers.DateTimeField(read_only=True)
    category = serializers.CharField(max_length=255)
    scope = serializers.CharField(source="workspace.scope.name", read_only=True)
    workspace = WorkspaceInCurrentScopeField(slug_field="name")
    # Deliberately incompatible with Serializer.data.
    data = serializers.DictField()  # type: ignore[assignment]
    work_request = serializers.IntegerField(
        allow_null=True, required=False, source="created_by_work_request_id"
    )

    def validate(self, data: dict[str, Any]) -> dict[str, Any]:
        """Validate category and data."""
        if data["category"] not in AssetCategory:
            raise serializers.ValidationError(
                {"category": f"{data['category']} is not a known category."}
            )

        try:
            asset_data_model(data["category"], data["data"])
        except ValueError as e:
            raise serializers.ValidationError(
                {"data": f"invalid asset data: {e}"}
            ) from e
        return data

    def create(self, validated_data: dict[str, Any]) -> Asset:
        """Create an Asset model from deserialized data."""
        data = copy(validated_data)
        if not data.get("created_by_work_request_id"):
            data["created_by"] = context.user
        return Asset.objects.create(**data)


class AssetPermissionCheckRequest(
    DebusineSerializerMixin[Never], serializers.Serializer[Never]
):
    """Serializer for a permission check on an asset."""

    validation_error_title = "Cannot deserialize permission check request"

    artifact_id = serializers.IntegerField()
    work_request_id = serializers.IntegerField()
    workspace = serializers.CharField()


class AssetPermissionCheckResponse(
    DebusineSerializerMixin[Never], serializers.Serializer[Never]
):
    """Serializer for a permission check result."""

    validation_error_title = "Cannot serialize permission check result"

    has_permission = serializers.BooleanField()
    username = serializers.CharField(max_length=150, required=False)
    user_id = serializers.IntegerField(required=False)
    resource = serializers.DictField(
        child=serializers.CharField(max_length=255), required=False
    )


class LookupExpectTypeChoiceField(serializers.ChoiceField):
    """
    A field for `Lookup*.expect_type`.

    This handles values of `LookupChildType`, but also accepts values of
    `CollectionItem.Types` for compatibility with old clients.
    """

    def __init__(self, **kwargs: Any) -> None:
        """Initialize the field."""
        super().__init__(choices=list(LookupChildType))

    def to_internal_value(self, data: Any) -> str:
        """Accept values of `CollectionItemTypes` for compatibility."""
        if data in CollectionItem.Types:
            data = {
                CollectionItem.Types.BARE: LookupChildType.BARE,
                CollectionItem.Types.ARTIFACT: LookupChildType.ARTIFACT,
                CollectionItem.Types.COLLECTION: LookupChildType.COLLECTION,
            }[data]
        return super().to_internal_value(data)


class LookupSingleSerializer(serializers.Serializer[Never]):
    """Serializer for the data used to look up a single collection item."""

    lookup = serializers.JSONField()
    default_category = serializers.CharField(
        max_length=255, required=False, allow_null=True
    )
    workspace = WorkspaceInCurrentScopeField(slug_field="name")
    workflow_root = serializers.PrimaryKeyRelatedField(
        queryset=WorkRequest.objects, required=False, allow_null=True
    )
    expect_type = LookupExpectTypeChoiceField()


class LookupMultipleSerializer(serializers.Serializer[Never]):
    """Serializer for the data used to look up multiple collection items."""

    lookup = serializers.JSONField()
    default_category = serializers.CharField(
        max_length=255, required=False, allow_null=True
    )
    workspace = WorkspaceInCurrentScopeField(slug_field="name")
    workflow_root = serializers.PrimaryKeyRelatedField(
        queryset=WorkRequest.objects, required=False, allow_null=True
    )
    expect_type = LookupExpectTypeChoiceField()


class LookupResponseSerializer(serializers.Serializer[LookupResult]):
    """Serializer for the response to looking up collection items."""

    media_type = "application/json"

    result_type = serializers.ChoiceField(choices=list(LookupResultType))
    collection_item: "serializers.PrimaryKeyRelatedField[CollectionItem]" = (
        serializers.PrimaryKeyRelatedField(read_only=True, allow_null=True)
    )
    artifact: "serializers.PrimaryKeyRelatedField[Artifact]" = (
        serializers.PrimaryKeyRelatedField(read_only=True, allow_null=True)
    )
    collection: "serializers.PrimaryKeyRelatedField[Collection]" = (
        serializers.PrimaryKeyRelatedField(read_only=True, allow_null=True)
    )


class WorkflowTemplateSerializer(
    LinksSerializerMixin[WorkflowTemplate],
    DebusineSerializerMixin[WorkflowTemplate],
    serializers.ModelSerializer[WorkflowTemplate],
):
    """Serializer for workflow templates."""

    validation_error_title = "Cannot deserialize workflow template"

    media_type = "application/json"

    class Meta:
        model = WorkflowTemplate
        fields = [
            "id",
            "name",
            "task_name",
            "static_parameters",
            "runtime_parameters",
            "priority",
        ]

    def to_representation(self, instance: WorkflowTemplate) -> dict[str, Any]:
        """Sort fields according to the model order."""
        res = super().to_representation(instance)

        try:
            workflow_cls = Workflow.from_name(instance.task_name)
            expected_order = list(
                workflow_cls.task_data_type.model_fields.keys()
            )
        except (ValueError, AttributeError):
            return res

        for field_name in ["static_parameters", "runtime_parameters"]:
            if field_name in res and isinstance(res[field_name], dict):
                params = res[field_name]

                sorted_params = {
                    key: params[key] for key in expected_order if key in params
                }
                for key, value in params.items():
                    if key not in sorted_params:
                        sorted_params[key] = value
                res[field_name] = sorted_params

        return res

    def get_links(self, instance: WorkflowTemplate) -> dict[str, Link]:
        """Get links to add to the serialized record."""
        res = super().get_links(instance)
        res["self"] = Link(
            href=reverse(
                "api:workflow-template-detail",
                kwargs={
                    "workspace": instance.workspace.name,
                    "pk": instance.pk,
                },
            )
        )
        res["webui_self"] = Link(href=instance.get_absolute_url())
        return res

    def validate_task_name(self, value: str) -> str:
        """
        Ensure that task_name is valid.

        We don't currently validate task_data, since a template's task data
        is allowed to be incomplete.
        """
        try:
            Workflow.from_name(value)
        except ValueError as exc:
            raise serializers.ValidationError({"task_name": str(exc)})

        return value


class CreateWorkflowRequestSerializer(
    DebusineSerializerMixin[WorkRequest],
    serializers.ModelSerializer[WorkRequest],
):
    """Serializer for requests to create workflows."""

    validation_error_title = "Cannot deserialize workflow creation request"

    media_type = "application/json"

    template_name = serializers.CharField(max_length=255)
    workspace = WorkspaceInCurrentScopeField(slug_field="name")

    class Meta:
        model = WorkRequest
        fields = [
            "template_name",
            "workspace",
            "task_data",
        ]


class PydanticSerializerMixin[PydanticModel: pydantic.BaseModel](
    SerializerMixinBase[PydanticModel]
):
    """Serialize pydantic models."""

    pydantic_model: type[PydanticModel]

    def to_representation(self, instance: PydanticModel) -> dict[str, Any]:
        """Delegate representation to pydantic."""
        return instance.model_dump()

    def to_internal_value(self, data: Any) -> PydanticModel:
        """Delegate validation to pydantic."""
        return self.pydantic_model(**data)


class DebusineTaskConfigurationSerializer(
    DebusineSerializerMixin[DebusineTaskConfiguration],
    PydanticSerializerMixin[DebusineTaskConfiguration],
):
    """Serialize a DebusineTaskConfiguration item."""

    pydantic_model = DebusineTaskConfiguration


class TaskConfigurationCollectionSerializer(
    DebusineSerializerMixin[Collection], serializers.ModelSerializer[Collection]
):
    """Serializer for a TaskConfiguration collection."""

    validation_error_title = "Cannot deserialize task configuration collection"
    media_type = "application/json"

    id = serializers.IntegerField()

    class Meta:
        model = Collection
        fields = ["id", "name", "data"]


class TaskConfigurationCollectionContents(NamedTuple):
    """Bundle together a TaskConfiguration collection and its items."""

    collection: Collection
    items: list[DebusineTaskConfiguration]


class TaskConfigurationCollectionContentsSerializer(
    DebusineSerializerMixin[TaskConfigurationCollectionContents],
):
    """Serializer for the contents of a TaskConfiguration collection."""

    validation_error_title = "Cannot deserialize task configuration collection"
    media_type = "application/json"

    collection = TaskConfigurationCollectionSerializer()
    items = DebusineTaskConfigurationSerializer(many=True)

    def validate_items(
        self, value: list[DebusineTaskConfiguration]
    ) -> list[DebusineTaskConfiguration]:
        """Ensure the collection items do not have conflicting names."""
        names: set[str] = set()
        for item in value:
            if (name := item.name()) in names:
                raise serializers.ValidationError(
                    f"{name!r}: found multiple items with the same name"
                )
            names.add(name)
        return value


class TaskConfigurationCollectionUpdateResultsSerializer(
    DebusineSerializerMixin[dict[str, int]],
    serializers.Serializer[dict[str, int]],
):
    """Serializer for the result information of a task configuration update."""

    validation_error_title = (
        "Cannot deserialize task configuration collection update results"
    )

    added = serializers.IntegerField()
    updated = serializers.IntegerField()
    removed = serializers.IntegerField()
    unchanged = serializers.IntegerField()


class WorkspaceChainItemField(
    FieldBase[Workspace, dict[str, Any], dict[str, Any], Workspace]
):
    """Serializers for a Workspace in a workspace chain."""

    def to_representation(self, value: Workspace) -> dict[str, Any]:
        """Trivially serialize workspaces with IDs."""
        return WorkspaceInheritanceChainElement(
            id=value.id,
            scope=value.scope.name,
            workspace=value.name,
        ).model_dump(mode="json")

    def _workspace_from_id(
        self, el: WorkspaceInheritanceChainElement
    ) -> Workspace:
        assert el.id is not None

        workspace_qs = Workspace.objects.can_display(context.pc).select_related(
            "scope"
        )
        try:
            workspace = workspace_qs.get(pk=el.id)
        except Workspace.DoesNotExist:
            raise ValidationError(f"Workspace with id {el.id} does not exist")
        if el.workspace is not None and workspace.name != el.workspace:
            raise ValidationError(
                f"Workspace with id {el.id} is not named {el.workspace!r}"
            )
        if el.scope is not None and workspace.scope.name != el.scope:
            raise ValidationError(
                f"Workspace with id {el.id} is not in scope {el.scope!r}"
            )
        return workspace

    def _workspace_from_scope_workspace(
        self, el: WorkspaceInheritanceChainElement
    ) -> Workspace:
        assert el.workspace is not None

        workspace_qs = Workspace.objects.can_display(context.pc).select_related(
            "scope"
        )

        if el.scope is None:
            el.scope = context.require_scope().name

        try:
            return workspace_qs.get(scope__name=el.scope, name=el.workspace)
        except Workspace.DoesNotExist:
            raise ValidationError(
                f"Workspace {el.workspace!r}"
                f" does not exist in scope {el.scope!r}"
            )

    def to_internal_value(self, data: Any) -> Workspace:
        """Parse a workspace from IDs or ``scope/name`` strings."""
        if not isinstance(data, dict):
            raise ValidationError("Workspace indicator should be a dict")

        try:
            parsed = WorkspaceInheritanceChainElement(**data)
        except ValueError as e:
            raise ValidationError(str(e))

        if parsed.id is not None:
            return self._workspace_from_id(parsed)
        else:
            return self._workspace_from_scope_workspace(parsed)


class WorkspaceChainSerializer(serializers.Serializer[Never]):
    """Serializer for a workspace chain."""

    chain = serializers.ListField(
        # Set an arbitrary high value to prevent a misguided API call impact
        # server performance
        child=WorkspaceChainItemField(),
        max_length=100,
    )


class DurationInDays(FieldBase[dt.timedelta, int, int, dt.timedelta]):
    """Represent a duration field reduced to a number of days."""

    def to_representation(self, value: dt.timedelta) -> int:
        """Represent a timedelta as a number of days."""
        days = value.days
        if value.seconds > 0 or value.microseconds > 0:
            days += 1
        return days

    def to_internal_value(self, data: int) -> dt.timedelta:
        """Parse a timedelta from a number of days."""
        return dt.timedelta(days=int(data))


class CollectionSerializer(
    LinksSerializerMixin[Collection],
    DebusineSerializerMixin[Collection],
    serializers.ModelSerializer[Collection],
):
    """Serializer of a Collection."""

    validation_error_title = "Cannot deserialize collection"

    category = serializers.ChoiceField(
        choices=list(CollectionCategory), required=False
    )
    name = serializers.CharField(required=False)
    full_history_retention_period = DurationInDays(
        allow_null=True, required=False
    )
    metadata_only_retention_period = DurationInDays(
        allow_null=True, required=False
    )
    stats = serializers.SerializerMethodField()

    def get_links(self, instance: Collection) -> dict[str, Link]:
        """Get links to add to the serialized record."""
        res = super().get_links(instance)
        res["self"] = Link(
            href=reverse(
                "api:collection-detail",
                kwargs={
                    "workspace": instance.workspace.name,
                    "pk": instance.pk,
                },
            )
        )
        res["webui_self"] = Link(href=instance.get_absolute_url())
        res["webui_category"] = Link(
            href=reverse(
                "workspaces:collections:category_list",
                kwargs={
                    "wname": instance.workspace.name,
                    "ccat": instance.category,
                },
            ),
            type="category",
        )
        return res

    def get_stats(self, instance: Collection) -> list[dict[str, Any]] | None:
        """Get statistics on the collections if requested."""
        if not self.context.get("with_stats"):
            return None
        return [
            {
                "type": x["type"].name.lower(),
                "category": x["category"],
                "active": x["active"],
                "count": x["count"],
            }
            for x in instance.get_item_stats()
        ]

    def validate_name(self, value: str) -> str:
        """Ensure that name is valid."""
        validate_collection_name(value)
        return value

    class Meta:
        model = Collection
        fields = [
            "id",
            "category",
            "name",
            "full_history_retention_period",
            "metadata_only_retention_period",
            "data",
            "stats",
        ]


class CollectionIdentifierSerializer(serializers.Serializer[Any]):
    """Identify collections based on the category and name."""

    name = serializers.CharField()
    category = serializers.ChoiceField(choices=list(CollectionCategory))


class CollectionRelationSerializer(
    DebusineSerializerMixin[CollectionRelation],
    serializers.ModelSerializer[CollectionRelation],
):
    """Serializer for relations between collections."""

    validation_error_title = "Cannot deserialize collection relation"

    # The attribute name "source" cannot be used (mypy complains). Use
    # "source_collection", use "source" parameter to map to the API field
    source_collection = CollectionIdentifierSerializer(source="source")
    target_collection = CollectionIdentifierSerializer(source="target")

    class Meta:
        model = CollectionRelation
        fields = [
            "source_collection",
            "target_collection",
            "type",
            "position",
        ]


class CollectionRelationSetSerializer(serializers.Serializer[Any]):
    """
    Serializer for setting the targets for a relation type.

    Used for the request and response.
    """

    targets = CollectionIdentifierSerializer(many=True)


class EmitMetricSerializer(serializers.Serializer[Never]):
    """Serializer for a metric to be emitted."""

    metric_type = serializers.ChoiceField(
        choices=("counter", "gauge", "summary", "histogram")
    )
    name = serializers.CharField()
    labels = serializers.DictField(
        child=serializers.CharField(), allow_empty=True
    )
    value = serializers.FloatField()
