# 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, TYPE_CHECKING, TypeAlias, override

import pydantic
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import QuerySet
from django.http import HttpRequest
from django.urls import reverse
from django.utils.encoding import smart_str
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,
    CollectionRelation,
    CollectionTokenGrant,
    File,
    Group,
    Token,
    User,
    WorkRequest,
    WorkflowTemplate,
    Workspace,
)
from debusine.db.models.auth import GroupQuerySet
from debusine.db.models.collections import 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
    RelatedFieldBase: TypeAlias = serializers.RelatedField
    SlugRelatedFieldBase: TypeAlias = serializers.SlugRelatedField
    FieldBase: TypeAlias = serializers.Field
else:
    # REST framework's serializers don't support generic types at run-time
    # yet.
    from debusine.utils.typing_utils import patch_runtime_generic_support

    BaseSerializerBase = patch_runtime_generic_support(
        serializers.BaseSerializer
    )
    SerializerMixinBase = patch_runtime_generic_support(serializers.Serializer)
    RelatedFieldBase = patch_runtime_generic_support(serializers.RelatedField)
    SlugRelatedFieldBase = patch_runtime_generic_support(
        serializers.SlugRelatedField
    )
    FieldBase = patch_runtime_generic_support(serializers.Field)


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

    validation_error_title: str

    @override
    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)

    @override
    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 {}

    @override
    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)

    @override
    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
            )
        )

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

    @override
    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
    )

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

    @override
    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",
        ]

    @override
    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"
    )

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

    @override
    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))

    @override
    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 CollectionItemSerializer(serializers.Serializer[CollectionItem]):
    """Serialize the collection item for nesting in the response."""

    name = serializers.CharField()
    # Deliberately incompatible with Serializer.data.
    data = serializers.JSONField(allow_null=True)  # type: ignore[assignment]


class LookupResponseSerializer(
    LinksSerializerMixin[LookupResult], 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 = CollectionItemSerializer(read_only=True, allow_null=True)
    parent_collection_lookup = serializers.CharField(
        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)
    )

    @override
    def get_links(self, instance: LookupResult) -> dict[str, Link]:
        """Get links to add to the serialized record."""
        res = super().get_links(instance)
        result: Artifact | Collection | None = None
        self_link: Link | None = None
        match instance.result_type:
            case LookupResultType.ARTIFACT:
                result = instance.artifact
                assert result is not None
                self_link = Link(
                    href=reverse(
                        "api:artifact", kwargs={"artifact_id": result.pk}
                    )
                )
            case LookupResultType.COLLECTION:
                result = instance.collection
                assert result is not None
                self_link = Link(
                    href=reverse(
                        "api:collection-detail",
                        kwargs={
                            "workspace": result.workspace.name,
                            "pk": result.pk,
                        },
                    )
                )
        if result is not None:
            assert self_link is not None
            res["self"] = self_link
            res["webui_self"] = Link(href=result.get_absolute_url())
        if instance.parent_collection_lookup:
            assert instance.collection_item is not None
            parent_collection = instance.collection_item.parent_collection
            assert parent_collection is not None
            res["parent_collection"] = Link(
                href=reverse(
                    "api:collection-detail",
                    kwargs={
                        "workspace": parent_collection.workspace.name,
                        "pk": parent_collection.pk,
                    },
                )
            )
            res["webui_parent_collection"] = Link(
                href=parent_collection.get_absolute_url()
            )
        return res


class ExistingGroupSerializer(RelatedFieldBase[Group, int | str, str]):
    """Read-only group reference, by ID or name."""

    default_error_messages = {
        "does_not_exist": "Group {value} does not exist.",
        "invalid": "Invalid value.",
    }

    @override
    def to_internal_value(self, data: int | str) -> Group:
        queryset = self.get_queryset()
        assert isinstance(queryset, GroupQuerySet)
        try:
            match data:
                case int():
                    res = queryset.get(pk=data)
                    assert isinstance(res, Group)
                    return res
                case str():
                    return queryset.get_by_name(data)
                case _:
                    self.fail('invalid')
        except ObjectDoesNotExist:
            self.fail('does_not_exist', value=smart_str(data))
        except (TypeError, ValueError):
            self.fail('invalid')

    # obj should be Group | PKOnlyObject according to rest framework stubs, but
    # I'm not sure how to express that. Using a type: ignore for now
    @override
    def to_representation(self, obj: Group) -> str:  # type: ignore[override]
        return str(obj)


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

    validation_error_title = "Cannot deserialize workflow template"

    media_type = "application/json"

    extra_groups = ExistingGroupSerializer(
        many=True, required=False, queryset=Group.objects.all()
    )

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

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

        res["extra_groups"].sort()

        return res

    @override
    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(
        default="", max_length=255, allow_blank=True
    )
    task_name = serializers.CharField(
        default="", max_length=255, allow_blank=True
    )
    workspace = WorkspaceInCurrentScopeField(slug_field="name")

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

    @override
    def validate(self, data: Any) -> Any:
        """Exactly one of ``template_name`` and ``task_name`` must be set."""
        if bool(data.get("template_name")) == bool(data.get("task_name")):
            raise serializers.ValidationError(
                "Exactly one of template_name and task_name must be set"
            )
        return data


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

    pydantic_model: type[PydanticModel]

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

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

    @override
    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}"
            )

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

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

    @override
    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()

    @override
    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 CollectionTokenGrantSerializer(
    serializers.ModelSerializer[CollectionTokenGrant]
):
    """Serializer for a CollectionTokenGrant."""

    token_type = serializers.CharField(
        source="token.token_type", required=False
    )
    token_hash = serializers.CharField(
        source="token.hash", default="", max_length=64, allow_blank=True
    )
    token_key = serializers.CharField(source="token.key", required=False)
    username = serializers.CharField(
        source="token.get_username",
        default="",
        max_length=150,
        allow_blank=True,
    )
    comment = serializers.CharField(
        source="token.comment", default="", max_length=100, allow_blank=True
    )

    class Meta:
        model = CollectionTokenGrant
        fields = [
            "token_type",
            "token_hash",
            "token_key",
            "username",
            "comment",
            "role",
        ]

    @override
    def validate(self, data: dict[str, Any]) -> dict[str, Any]:
        """Validate consistency of token-related fields."""
        if not data["token"]["hash"] and not data["token"]["get_username"]:
            raise ValidationError("Must provide either token_hash or username")
        return data

    @override
    def create(self, validated_data: dict[str, Any]) -> CollectionTokenGrant:
        collection = validated_data["collection"]
        role = Collection.Roles(validated_data["role"])
        created_by = validated_data["created_by"]
        token_hash = validated_data["token"]["hash"]
        username = validated_data["token"]["get_username"]
        comment = validated_data["token"]["comment"]
        assert isinstance(collection, Collection)
        assert isinstance(created_by, User)
        assert isinstance(token_hash, str)
        assert isinstance(username, str)
        assert isinstance(comment, str)

        if token_hash:
            token = Token.objects.get(hash=token_hash)
            match token.token_type:
                # It would be easy to add support for user tokens as well if
                # we wanted to.
                case Token.Types.EXTERNAL_USER:
                    if username and username != token.external_username:
                        raise ValidationError(
                            f"Token {token} has username"
                            f" {token.external_username!r}, not {username!r}"
                        )
                case _:
                    raise ValidationError(
                        f"Cannot grant roles to {token.token_type} tokens"
                    )
        else:
            token = Token.objects.create_external_user_token(
                username=username, created_by=created_by, comment=comment
            )
            token.full_clean()
            token.enable()

        return collection.grant_role(token, role, created_by=created_by)


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