"""Client serializers for the messages core app."""
# pylint: disable=too-many-lines

import hashlib
import json
import uuid
from urllib.parse import urlparse

from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.db.models import Count, Max, Q
from django.middleware.csrf import get_token

from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema_field
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied

from core import enums, models
from core.mda.dispatch_webhooks import (
    VALID_FORMATS,
)
from core.mda.inline_images import extract_inline_images_html
from core.services.attachments import get_attachment_display_name
from core.services.blob_gc import schedule_for_gc
from core.services.identity import keycloak as keycloak_service
from core.services.importer.channel import merged_state
from core.services.ssrf import SSRFValidationError, validate_hostname


class CreateOnlyFieldsMixin:
    """Mixin that makes specified fields read-only on update (when instance exists).

    Usage:
        class MySerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
            class Meta:
                create_only_fields = ["mailbox", "thread"]
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if self.instance is not None:
            for field_name in getattr(self.Meta, "create_only_fields", []):
                if field_name in self.fields:
                    self.fields[field_name].read_only = True


@extend_schema_field({"type": "object", "additionalProperties": True})
class ObjectJSONField(serializers.JSONField):
    """JSONField annotated as ``type: object`` for OpenAPI schema generation."""


class ThreadEventUserSerializer(serializers.Serializer):
    """
    OpenAPI-only serializer: describes a single user inside
    an ThreadEvent.data payload. (used for ``IM`` and ``ASSIGNEES`` events)

    Not used for runtime validation (handled by
    ``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``);
    exists solely to produce a named component in the OpenAPI schema consumed
    by the generated frontend client.
    """

    id = serializers.UUIDField()
    name = serializers.CharField()

    def create(self, validated_data):
        """Do not allow creating instances from this serializer."""
        raise RuntimeError(f"{self.__class__.__name__} does not support create method")

    def update(self, instance, validated_data):
        """Do not allow updating instances from this serializer."""
        raise RuntimeError(f"{self.__class__.__name__} does not support update method")


class ThreadEventIMDataSerializer(serializers.Serializer):
    """OpenAPI-only serializer: shape of ``ThreadEvent.data`` for ``IM`` events.

    Not used for runtime validation (handled by
    ``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``);
    exists solely to produce a named component in the OpenAPI schema consumed
    by the generated frontend client.
    """

    content = serializers.CharField()
    mentions = ThreadEventUserSerializer(many=True, required=False)

    def create(self, validated_data):
        """Do not allow creating instances from this serializer."""
        raise RuntimeError(f"{self.__class__.__name__} does not support create method")

    def update(self, instance, validated_data):
        """Do not allow updating instances from this serializer."""
        raise RuntimeError(f"{self.__class__.__name__} does not support update method")


class ThreadEventAssigneesDataSerializer(serializers.Serializer):
    """OpenAPI-only serializer: shape of ``ThreadEvent.data`` for ``ASSIGN`` and ``UNASSIGN`` events.

    Both event types share the exact same payload shape, so a single serializer
    (and thus a single generated TypeScript type) covers them.

    Not used for runtime validation (handled by
    ``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``);
    exists solely to produce a named component in the OpenAPI schema consumed
    by the generated frontend client.
    """

    assignees = ThreadEventUserSerializer(many=True, min_length=1)

    def create(self, validated_data):
        """Do not allow creating instances from this serializer."""
        raise RuntimeError(f"{self.__class__.__name__} does not support create method")

    def update(self, instance, validated_data):
        """Do not allow updating instances from this serializer."""
        raise RuntimeError(f"{self.__class__.__name__} does not support update method")


@extend_schema_field(
    PolymorphicProxySerializer(
        component_name="ThreadEventData",
        serializers=[
            ThreadEventIMDataSerializer,
            ThreadEventAssigneesDataSerializer,
        ],
        resource_type_field_name=None,
    )
)
class ThreadEventDataField(serializers.JSONField):
    """JSONField for ``ThreadEvent.data``.

    Runtime validation is performed by ``ThreadEvent.validate_data()`` against
    ``ThreadEvent.DATA_SCHEMAS``, invoked both by ``ThreadEventSerializer``
    (to surface 400 errors at the HTTP boundary) and by
    ``ThreadEvent.clean()`` (defence in depth for ORM writes).
    The ``@extend_schema_field`` decorator only affects OpenAPI generation:
    it emits a named ``oneOf`` over the two ``*DataSerializer`` variants so
    orval produces stable, readable TypeScript types
    (``ThreadEventImData``, ``ThreadEventAssigneesData``) instead of
    positional ``OneOfN`` names.
    """


class IntegerChoicesField(serializers.ChoiceField):
    """
    Custom field to handle IntegerChoices that accepts string labels for input
    and returns string labels for output.

    Example usage:
        role = IntegerChoicesField(choices=MailboxRoleChoices)

    This field will:
    - Accept strings like "viewer", "editor", "admin" for input
    - Store them as integers (1, 2, 4) in the database
    - Return strings like "viewer", "editor", "admin" for output
    - Provide helpful error messages for invalid choices
    - Support backward compatibility with integer input
    """

    def __init__(self, choices_class, **kwargs):
        super().__init__(choices=choices_class.choices, **kwargs)
        self._override_spectacular_annotation(choices_class)

    def _override_spectacular_annotation(self, choices_class):
        """
        Override the OpenAPI annotation for the field.
        This method has the same effect than `extend_schema_field` decorator.
        We do that only to be able to use class attributes as choices that is not possible with the decorator.
        https://drf-spectacular.readthedocs.io/en/latest/drf_spectacular.html#drf_spectacular.utils.extend_schema_field
        """
        self._spectacular_annotation = {
            "field": {
                "type": "string",
                "enum": [label for _value, label in choices_class.choices],
            },
            "field_component_name": choices_class.__name__,
        }

    @extend_schema_field(
        {
            "type": "string",
            "enum": None,  # This will be set dynamically
            "description": "Choice field that accepts string labels and returns string labels",
        }
    )
    def to_representation(self, value):
        """Convert integer value to string label for output."""
        if value is None:
            return None
        enum_instance = self.choices[value]
        return enum_instance

    def to_internal_value(self, data):
        """Convert string label to integer value for storage."""
        if data is None:
            return None

        # If it's already an integer (for backward compatibility), validate and return it
        if isinstance(data, int):
            try:
                # Validate it's a valid choice
                self.choices[data]  # pylint: disable=pointless-statement
                return data
            except KeyError:
                self.fail("invalid_choice", input=data)

        # Convert string label to integer value
        if isinstance(data, str):
            for choice_value, choice_label in self.choices.items():
                if choice_label == data:
                    return choice_value
            self.fail("invalid_choice", input=data)

        self.fail("invalid_choice", input=data)

        return None

    default_error_messages = {
        "invalid_choice": "Invalid choice: {input}. Valid choices are: {choices}."
    }

    def fail(self, key, **kwargs):
        """Override to provide better error messages."""
        if key == "invalid_choice":
            valid_choices = [label for value, label in self.choices.items()]
            kwargs["choices"] = ", ".join(valid_choices)
        super().fail(key, **kwargs)


def nullable_choices_schema(choices_class):
    """Nullable enum schema for a ``SerializerMethodField`` that may return
    ``None``.

    Reuse the shared ``{choices_class.__name__}`` component (registered by the
    non-null :class:`IntegerChoicesField` usages) and apply the nullability
    locally via the ``allOf`` wrapper. Baking ``nullable`` into the component
    itself would collide with those non-null usages; an inline enum dict would
    instead make drf-spectacular extract a redundant ``…Enum`` component. This
    mirrors the shape drf-spectacular emits natively for a nullable ``$ref``.
    """
    return {
        "allOf": [{"$ref": f"#/components/schemas/{choices_class.__name__}"}],
        "nullable": True,
    }


class AbilitiesModelSerializer(serializers.ModelSerializer):
    """
    A ModelSerializer that takes an additional `exclude` argument that
    dynamically controls which fields should be excluded from the serializer.
    """

    def __init__(self, *args, **kwargs):
        """Add abilities field unless exclude_abilities is True."""
        if not hasattr(self, "exclude_abilities"):
            self.exclude_abilities = kwargs.pop("exclude_abilities", False)
        super().__init__(*args, **kwargs)

        # Add abilities field unless exclude_abilities is True
        if not self.exclude_abilities:
            abilities_field = serializers.SerializerMethodField(read_only=True)
            self.fields["abilities"] = abilities_field

    # This decorator is generic, override the `get_abilities` method
    # in the child serializer to provide the specific implementation if needed.
    @extend_schema_field(
        {
            "type": "object",
            "description": "Instance permissions and capabilities",
            "additionalProperties": {"type": "boolean"},
            "nullable": True,
        }
    )
    def get_abilities(self, instance):
        """Get abilities for the instance."""
        request = self.context.get("request")
        if not request:
            return {}

        if isinstance(instance, models.User):
            return instance.get_abilities()

        return instance.get_abilities(request.user)


class UserSerializer(AbilitiesModelSerializer):
    """Serialize users."""

    custom_attributes = serializers.SerializerMethodField(read_only=True)

    class Meta:
        model = models.User
        fields = ["id", "email", "full_name", "custom_attributes"]
        read_only_fields = fields

    @extend_schema_field(
        {
            "type": "object",
            "description": "Instance permissions and capabilities",
            "properties": {
                choice.value: {"type": "boolean", "description": choice.label}
                for choice in models.UserAbilities
            },
            "required": [choice.value for choice in models.UserAbilities],
        }
    )
    def get_abilities(self, instance):
        """Get abilities for the instance."""
        return super().get_abilities(instance)

    def get_custom_attributes(self, instance) -> dict:
        """Get custom attributes for the instance."""
        return instance.custom_attributes


class UserWithAbilitiesSerializer(UserSerializer):
    """
    Serialize users with abilities.
    Allow to have separated OpenAPI definition for users with and without abilities.
    """

    exclude_abilities = False
    csrf_token = serializers.SerializerMethodField(read_only=True)

    class Meta(UserSerializer.Meta):
        fields = UserSerializer.Meta.fields + ["csrf_token"]
        read_only_fields = fields

    def get_csrf_token(self, _instance) -> str:
        """Return the session-bound CSRF token for the SPA to echo as X-CSRFToken.

        With ``CSRF_USE_SESSIONS`` the secret lives in the session and is no
        longer exposed as a cookie, so the token is delivered here over the
        authenticated ``/users/me/`` channel.
        """
        return get_token(self.context["request"])


class UserWithoutAbilitiesSerializer(UserSerializer):
    """
    Serialize users without abilities.
    Allow to have separated OpenAPI definition for users with and without abilities.
    """

    exclude_abilities = True


class ThreadMentionableUserSerializer(UserWithoutAbilitiesSerializer):
    """User listed in a thread's mention suggestions, with comment capability flag."""

    can_post_comments = serializers.BooleanField(read_only=True)

    class Meta(UserWithoutAbilitiesSerializer.Meta):
        fields = UserWithoutAbilitiesSerializer.Meta.fields + ["can_post_comments"]
        read_only_fields = fields


class MailboxAvailableSerializer(serializers.ModelSerializer):
    """Serialize mailboxes."""

    contact = serializers.SerializerMethodField(read_only=True)
    email = serializers.SerializerMethodField(read_only=True)

    def get_contact(self, instance):
        """Return the contact of the mailbox."""
        if instance.contact:
            return instance.contact.name
        return None

    def get_email(self, instance):
        """Return the email of the mailbox."""
        return str(instance)

    class Meta:
        model = models.Mailbox
        fields = ["id", "email", "contact"]


class MailboxSerializer(AbilitiesModelSerializer):
    """Serialize mailboxes."""

    email = serializers.SerializerMethodField(read_only=True)
    name = serializers.SerializerMethodField(read_only=True)
    domain_id = serializers.UUIDField(read_only=True)
    role = serializers.SerializerMethodField(read_only=True)
    count_unread_threads = serializers.SerializerMethodField(read_only=True)
    count_threads = serializers.SerializerMethodField(read_only=True)
    count_delivering = serializers.SerializerMethodField(read_only=True)
    count_unread_mentions = serializers.SerializerMethodField(read_only=True)
    count_assigned = serializers.SerializerMethodField(read_only=True)
    is_shared = serializers.SerializerMethodField(read_only=True)

    class Meta:
        model = models.Mailbox
        fields = [
            "id",
            "email",
            "name",
            "domain_id",
            "is_identity",
            "is_shared",
            "role",
            "count_unread_threads",
            "count_threads",
            "count_delivering",
            "count_unread_mentions",
            "count_assigned",
        ]
        read_only_fields = fields

    def get_email(self, instance):
        """Return the email of the mailbox."""
        return str(instance)

    def get_name(self, instance) -> str | None:
        """Return the display name of the mailbox (its contact name)."""
        if instance.contact:
            return instance.contact.name
        return None

    @extend_schema_field(nullable_choices_schema(models.MailboxRoleChoices))
    def get_role(self, instance):
        """Return the allowed actions of the logged-in user on the instance."""
        # Use the annotated user_role field
        if hasattr(instance, "user_role") and instance.user_role is not None:
            try:
                role_enum = models.MailboxRoleChoices(instance.user_role)
                return role_enum.label
            except ValueError:
                return None

        # Fallback for backward compatibility
        request = self.context.get("request")
        if request:
            try:
                role_enum = models.MailboxRoleChoices(
                    instance.accesses.get(user=request.user).role
                )
                return role_enum.label
            except models.MailboxAccess.DoesNotExist:
                return None
        return None

    def _get_cached_counts(self, instance):
        """Get or compute cached counts for the instance."""
        cache_key = f"_counts_{instance.pk}"
        if not hasattr(self, cache_key):
            counts = instance.thread_accesses.aggregate(
                count_unread_threads=Count(
                    "id",
                    filter=models.ThreadAccess.unread_filter(),
                ),
                count_threads=Count("thread"),
                count_delivering=Count(
                    "thread",
                    filter=Q(thread__has_delivery_pending=True),
                    distinct=True,
                ),
            )
            # Count distinct threads in this mailbox with an active unread
            # mention UserEvent for the current user. Done in a separate query
            # to avoid join-multiplication breaking the other counts above.
            request = self.context.get("request")
            if request and request.user.is_authenticated:
                counts["count_unread_mentions"] = (
                    models.UserEvent.objects.filter(
                        user=request.user,
                        type=enums.UserEventTypeChoices.MENTION,
                        read_at__isnull=True,
                        thread__accesses__mailbox=instance,
                    )
                    .values("thread_id")
                    .distinct()
                    .count()
                )
                # Count distinct threads in this mailbox where the current user
                # is currently assigned. UserEvent(type=ASSIGN) is the source
                # of truth: created on assign, deleted on unassign — so a
                # plain existence query reflects the live state. Polled
                # client-side to detect cross-user assignments without push.
                counts["count_assigned"] = (
                    models.UserEvent.objects.filter(
                        user=request.user,
                        type=enums.UserEventTypeChoices.ASSIGN,
                        thread__accesses__mailbox=instance,
                    )
                    .values("thread_id")
                    .distinct()
                    .count()
                )
            else:
                counts["count_unread_mentions"] = 0
                counts["count_assigned"] = 0
            counts["count_accesses"] = instance.accesses.count()
            setattr(self, cache_key, counts)
        return getattr(self, cache_key)

    def get_count_unread_threads(self, instance) -> int:
        """Return the number of threads with unread messages in the mailbox."""
        return self._get_cached_counts(instance)["count_unread_threads"]

    def get_count_threads(self, instance) -> int:
        """Return the number of threads in the mailbox."""
        return self._get_cached_counts(instance)["count_threads"]

    def get_count_delivering(self, instance) -> int:
        """Return the number of threads with messages being delivered."""
        return self._get_cached_counts(instance)["count_delivering"]

    def get_count_unread_mentions(self, instance) -> int:
        """Return the number of threads with unread mentions for the current user."""
        return self._get_cached_counts(instance)["count_unread_mentions"]

    def get_count_assigned(self, instance) -> int:
        """Return the number of threads in this mailbox currently assigned to the current user."""
        return self._get_cached_counts(instance)["count_assigned"]

    def get_is_shared(self, instance) -> bool:
        """Return True if the mailbox is shared (non-identity or has more than one access).

        Drives mailbox-level UI gating for collaboration features (assignment
        sub-folders, mention folder) that have no purpose in a mono-user
        identity mailbox.
        """
        return (not instance.is_identity) or self._get_cached_counts(instance)[
            "count_accesses"
        ] > 1

    @extend_schema_field(
        {
            "type": "object",
            "description": "Instance permissions and capabilities",
            "properties": {
                choice.value: {"type": "boolean", "description": choice.label}
                for choice in [*models.CRUDAbilities, *models.MailboxAbilities]
            },
            "required": [
                choice.value
                for choice in [*models.CRUDAbilities, *models.MailboxAbilities]
            ],
        }
    )
    def get_abilities(self, instance):
        """Get abilities for the instance."""
        return super().get_abilities(instance)


class MailboxNameUpdateSerializer(serializers.Serializer):
    """Validate and apply a mailbox display-name update (its contact name)."""

    name = serializers.CharField(max_length=255)

    def validate_name(self, value):
        """Strip surrounding whitespace and reject whitespace-only names, which
        would otherwise be stored (and sent in the ``From`` header) verbatim."""
        value = value.strip()
        if not value:
            raise serializers.ValidationError("Name cannot be blank.")
        return value

    def create(self, validated_data):
        """Do not allow creating instances from this serializer."""
        raise RuntimeError(f"{self.__class__.__name__} does not support create method")

    def update(self, instance, validated_data):
        """Persist the new display name through the mailbox helper.

        The PATCH is partial: an absent ``name`` is a no-op, so callers may send
        an empty body without error. This matches the optional ``name`` in the
        generated OpenAPI request schema.
        """
        if "name" in validated_data:
            instance.set_display_name(validated_data["name"])
        return instance


class MailboxLightSerializer(serializers.ModelSerializer):
    """Serializer for mailbox details in thread access."""

    email = serializers.SerializerMethodField(read_only=True)
    name = serializers.SerializerMethodField(read_only=True)

    class Meta:
        model = models.Mailbox
        fields = ["id", "email", "name", "is_identity"]
        read_only_fields = fields

    def get_email(self, instance):
        """Return the email of the mailbox."""
        return str(instance)

    def get_name(self, instance):
        """Return the contact of the mailbox."""
        if instance.contact:
            return instance.contact.name
        return None


class ReadMessageTemplateSerializer(serializers.ModelSerializer):
    """Serialize message templates with dynamic body field inclusion.

    Body fields (html_body, text_body, raw_body) are only included when
    explicitly requested via the ``?bodies=`` query parameter or the
    ``body_fields`` keyword argument (for nested usage).

    Allowed values: ``raw``, ``html``, ``text`` (comma-separated).
    Mapping: ``raw`` → ``raw_body``, ``html`` → ``html_body``, ``text`` → ``text_body``.

    When neither query param nor kwarg is provided, no body field is returned.
    """

    BODY_FIELD_MAP = {
        "raw": "raw_body",
        "html": "html_body",
        "text": "text_body",
    }

    type = IntegerChoicesField(choices_class=enums.MessageTemplateTypeChoices)
    # Not marked read_only so that drf-spectacular (with COMPONENT_SPLIT_REQUEST)
    # correctly treats them as optional in the response schema. This serializer
    # is never used for write operations.
    html_body = serializers.CharField(required=False, allow_null=True, default=None)
    text_body = serializers.CharField(required=False, allow_null=True, default=None)
    raw_body = serializers.CharField(required=False, allow_null=True, default=None)
    metadata = ObjectJSONField(required=False, default=dict)
    is_active_autoreply = serializers.SerializerMethodField()
    signature = serializers.PrimaryKeyRelatedField(read_only=True)

    @extend_schema_field(serializers.BooleanField(allow_null=True))
    def get_is_active_autoreply(self, obj):
        """Return whether the autoreply is currently active based on schedule."""
        if obj.type != enums.MessageTemplateTypeChoices.AUTOREPLY:
            return None
        return obj.is_active_autoreply()

    def __init__(self, *args, **kwargs):
        body_fields = kwargs.pop("body_fields", None)
        super().__init__(*args, **kwargs)

        # Determine which body fields (html_body, text_body, raw_body) to keep.
        # When there is no request context and no explicit body_fields kwarg,
        # keep all fields (this is the case for OpenAPI schema introspection).
        request = self.context.get("request")
        if body_fields is None and request is None:
            return

        requested = set()
        if body_fields is not None:
            requested = set(body_fields)
        elif request:
            for value in request.query_params.getlist("bodies"):
                for part in value.split(","):
                    field_name = part.strip()
                    if field_name in self.BODY_FIELD_MAP:
                        requested.add(field_name)

        # Remove unrequested body fields
        all_body_keys = set(self.BODY_FIELD_MAP.keys())
        for key in all_body_keys - requested:
            self.fields.pop(self.BODY_FIELD_MAP[key], None)

    class Meta:
        model = models.MessageTemplate
        fields = [
            "id",
            "name",
            "html_body",
            "text_body",
            "raw_body",
            "type",
            "is_active",
            "is_forced",
            "is_default",
            "metadata",
            "signature",
            "is_active_autoreply",
            "created_at",
            "updated_at",
        ]
        read_only_fields = fields


class ContactSerializer(serializers.ModelSerializer):
    """Serialize contacts."""

    class Meta:
        model = models.Contact
        fields = ["id", "name", "email"]


class BlobSerializer(serializers.ModelSerializer):
    """Serialize blobs."""

    blobId = serializers.UUIDField(source="id", read_only=True)
    type = serializers.CharField(source="content_type", read_only=True)
    sha256 = serializers.SerializerMethodField()

    def get_sha256(self, obj):
        """Convert binary SHA256 to hex string."""
        return obj.sha256.hex() if obj.sha256 else None

    class Meta:
        model = models.Blob
        fields = [
            "blobId",
            "size",
            "type",
            "sha256",
            "created_at",
        ]
        read_only_fields = fields


class AttachmentSerializer(serializers.ModelSerializer):
    """Serialize attachments."""

    blobId = serializers.UUIDField(source="blob.id", read_only=True)
    type = serializers.CharField(source="content_type", read_only=True)
    sha256 = serializers.SerializerMethodField()
    cid = serializers.CharField(
        read_only=True, allow_null=True, help_text="Content-ID for inline images"
    )

    def get_sha256(self, obj):
        """Convert binary SHA256 to hex string."""
        return obj.sha256.hex() if obj.sha256 else None

    class Meta:
        model = models.Attachment
        fields = [
            "blobId",
            "name",
            "size",
            "type",
            "sha256",
            "created_at",
            "cid",
        ]
        read_only_fields = fields


class ThreadLabelSerializer(serializers.ModelSerializer):
    """Serializer to get labels details for a thread."""

    display_name = serializers.CharField(source="get_display_name", read_only=True)

    class Meta:
        model = models.Label
        fields = [
            "id",
            "name",
            "slug",
            "color",
            "display_name",
            "description",
            "is_auto",
        ]
        read_only_fields = ["id", "slug", "display_name"]


class TreeLabelSerializer(serializers.ModelSerializer):
    """Serializer for tree label response structure (OpenAPI purpose only...)."""

    id = serializers.UUIDField(read_only=True)
    name = serializers.CharField(read_only=True)
    slug = serializers.CharField(read_only=True)
    color = serializers.CharField(read_only=True)
    display_name = serializers.CharField(source="get_display_name", read_only=True)
    children = serializers.SerializerMethodField(read_only=True)
    description = serializers.CharField(read_only=True)
    is_auto = serializers.BooleanField(read_only=True)

    class Meta:
        model = models.Label
        fields = [
            "id",
            "name",
            "slug",
            "color",
            "display_name",
            "children",
            "description",
            "is_auto",
        ]
        read_only_fields = fields

    @extend_schema_field(
        {"type": "array", "items": {"$ref": "#/components/schemas/TreeLabel"}}
    )
    def get_children(self, instance):
        """
        Fake method just to make the OpenAPI schema valid and work well with
        the recursive nature of the tree label structure.
        """


class LabelSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
    """Serializer for Label model."""

    display_name = serializers.CharField(source="get_display_name", read_only=True)

    class Meta:
        model = models.Label
        fields = [
            "id",
            "name",
            "slug",
            "color",
            "display_name",
            "mailbox",
            "threads",
            "description",
            "is_auto",
        ]
        read_only_fields = ["id", "slug", "display_name"]
        create_only_fields = ["mailbox"]

    def validate_mailbox(self, value):
        """Validate that user has access to the mailbox."""
        user = self.context["request"].user
        if not value.accesses.filter(
            user=user,
            role__in=enums.MAILBOX_ROLES_CAN_EDIT,
        ).exists():
            raise PermissionDenied("You don't have access to this mailbox")
        return value


class ThreadAccessDetailSerializer(serializers.ModelSerializer):
    """Serializer for thread access details."""

    mailbox = MailboxLightSerializer()
    role = IntegerChoicesField(
        choices_class=models.ThreadAccessRoleChoices, read_only=True
    )

    class Meta:
        model = models.ThreadAccess
        fields = ["id", "mailbox", "role", "read_at", "starred_at"]
        read_only_fields = fields


class ThreadSerializer(serializers.ModelSerializer):
    """Serialize threads."""

    messages = serializers.SerializerMethodField(read_only=True)
    sender_names = serializers.ListField(child=serializers.CharField(), read_only=True)
    user_role = serializers.SerializerMethodField(read_only=True)
    has_unread = serializers.SerializerMethodField(read_only=True)
    has_unread_mention = serializers.SerializerMethodField(read_only=True)
    has_starred = serializers.SerializerMethodField(read_only=True)
    accesses = serializers.SerializerMethodField()
    labels = serializers.SerializerMethodField()
    summary = serializers.CharField(read_only=True)
    events_count = serializers.IntegerField(read_only=True)
    abilities = serializers.SerializerMethodField(read_only=True)
    assigned_users = serializers.SerializerMethodField(read_only=True)

    @extend_schema_field(serializers.DictField(child=serializers.BooleanField()))
    def get_abilities(self, instance):
        """Return the current user's abilities on the thread, scoped to
        the mailbox context when provided. Frontend components use this
        to hide mutating actions (archive, spam, delete, reply...) from
        users with read-only access.

        Prefers the ``_can_edit`` annotation set by the viewset queryset
        (single SQL subquery for the whole page) and falls back to a
        per-instance query for code paths that build threads outside the
        annotated queryset (e.g. the split action).
        """
        can_edit = getattr(instance, "_can_edit", None)
        if can_edit is not None:
            return {enums.ThreadAbilities.CAN_EDIT: can_edit}

        request = self.context.get("request")
        if request is None:
            return {}
        return instance.get_abilities(
            request.user, mailbox_id=self.context.get("mailbox_id")
        )

    @extend_schema_field(serializers.BooleanField())
    def get_has_unread(self, instance):
        """Return whether the thread has unread messages for the current mailbox.

        Requires the _has_unread annotation (set by ThreadViewSet when mailbox_id is provided).
        Returns False when the annotation is absent (no mailbox context).
        """
        return getattr(instance, "_has_unread", False)

    @extend_schema_field(serializers.BooleanField())
    def get_has_unread_mention(self, instance):
        """Return whether the thread has unread mentions for the current user.

        Requires the _has_unread_mention annotation (set by ThreadViewSet).
        Returns False when the annotation is absent (no mailbox context).
        """
        return getattr(instance, "_has_unread_mention", False)

    @extend_schema_field(serializers.BooleanField())
    def get_has_starred(self, instance):
        """Return whether the thread is starred for the current mailbox.

        Requires the _has_starred annotation (set by ThreadViewSet when mailbox_id is provided).
        Returns False when the annotation is absent (no mailbox context).
        """
        return getattr(instance, "_has_starred", False)

    @extend_schema_field(ThreadAccessDetailSerializer(many=True))
    def get_accesses(self, instance):
        """Return the accesses for the thread.

        Uses the ``_accesses_with_mailbox`` prefetch cache when present;
        the fallback select_related mirrors the prefetch queryset so
        ``MailboxLightSerializer`` never lazy-loads the mail domain.
        """
        cached = getattr(instance, "_accesses_with_mailbox", None)
        if cached is None:
            cached = instance.accesses.select_related(
                "mailbox", "mailbox__domain", "mailbox__contact"
            )
        return ThreadAccessDetailSerializer(cached, many=True).data

    def get_messages(self, instance):
        """Return the IDs of the thread messages, chronologically ordered."""
        cached = getattr(instance, "_ordered_messages", None)
        if cached is None:
            cached = instance.messages.order_by("created_at")
        return [str(message.id) for message in cached]

    @extend_schema_field(nullable_choices_schema(models.ThreadAccessRoleChoices))
    def get_user_role(self, instance):
        """Get current user's role for this thread, scoped to the context mailbox.

        Walks the ``_accesses_with_mailbox`` prefetch cache when available
        to avoid a per-thread SQL round-trip; falls back to a direct query
        for code paths that build threads outside the annotated queryset
        (e.g. the ``split`` action).
        """
        mailbox_id = self.context.get("mailbox_id")
        if not mailbox_id:
            return None

        cached = getattr(instance, "_accesses_with_mailbox", None)
        if cached is not None:
            mailbox_id_str = str(mailbox_id)
            access = next(
                (a for a in cached if str(a.mailbox_id) == mailbox_id_str),
                None,
            )
            if access is None:
                return None
            return models.ThreadAccessRoleChoices(access.role).label

        try:
            role_value = instance.accesses.get(mailbox_id=mailbox_id).role
            return models.ThreadAccessRoleChoices(role_value).label
        except models.ThreadAccess.DoesNotExist:
            return None

    @extend_schema_field(ThreadLabelSerializer(many=True))
    def get_labels(self, instance):
        """Get labels for the thread, scoped to the context mailbox.

        Consumes the ``_scoped_labels`` prefetch cache when the viewset has
        populated it (requires ``mailbox_id``); otherwise falls back to a
        direct query, which keeps the ``split`` action and single-thread
        retrieves working.
        """
        mailbox_id = self.context.get("mailbox_id")
        if not mailbox_id:
            return []

        cached = getattr(instance, "_scoped_labels", None)
        if cached is None:
            cached = instance.labels.filter(mailbox_id=mailbox_id)
        return ThreadLabelSerializer(cached, many=True).data

    @extend_schema_field(ThreadEventUserSerializer(many=True))
    def get_assigned_users(self, instance):
        """Return the users currently assigned to this thread.

        ``UserEvent(type=ASSIGN)`` is the denormalized per-user source of truth
        for active assignments (rows are created on assign and deleted on
        unassign via ``delete_assign_user_events``), so the thread list can
        expose assignees without having to replay the ThreadEvent history.

        Uses the ``_assigned_user_events`` prefetch cache when present to
        avoid N+1 on list views; falls back to a direct query for code
        paths that build threads outside the annotated queryset.
        """
        cached = getattr(instance, "_assigned_user_events", None)
        if cached is None:
            cached = list(
                instance.user_events.filter(type=enums.UserEventTypeChoices.ASSIGN)
                .select_related("user")
                .order_by("created_at")
            )
        return [
            {"id": str(event.user.id), "name": event.user.full_name or ""}
            for event in cached
        ]

    class Meta:
        model = models.Thread
        fields = [
            "id",
            "subject",
            "snippet",
            "messages",
            "has_unread",
            "has_unread_mention",
            "has_trashed",
            "is_trashed",
            "has_archived",
            "has_draft",
            "has_starred",
            "has_attachments",
            "has_sender",
            "has_messages",
            "has_delivery_failed",
            "has_delivery_pending",
            "is_spam",
            "has_active",
            "messaged_at",
            "active_messaged_at",
            "draft_messaged_at",
            "sender_messaged_at",
            "archived_messaged_at",
            "trashed_messaged_at",
            "sender_names",
            "updated_at",
            "user_role",
            "accesses",
            "labels",
            "summary",
            "events_count",
            "abilities",
            "assigned_users",
        ]
        read_only_fields = fields  # Mark all as read-only for safety


class MessageRecipientSerializer(serializers.ModelSerializer):
    """Serialize message recipients."""

    contact = ContactSerializer(read_only=True)
    delivery_status = IntegerChoicesField(
        choices_class=models.MessageDeliveryStatusChoices,
        read_only=True,
        allow_null=True,
    )
    delivery_message = serializers.CharField(read_only=True, allow_null=True)
    retry_at = serializers.DateTimeField(read_only=True, allow_null=True)
    delivered_at = serializers.DateTimeField(read_only=True, allow_null=True)

    class Meta:
        model = models.MessageRecipient
        fields = [
            "id",
            "contact",
            "delivery_status",
            "delivery_message",
            "retry_at",
            "delivered_at",
        ]


class MessageBodyItemSerializer(serializers.Serializer):
    """Message body item serializer."""

    partId = serializers.CharField()
    type = serializers.CharField()
    content = serializers.CharField(allow_blank=True)

    def create(self, validated_data):
        """Do not allow creating instances from this serializer."""
        raise RuntimeError(f"{self.__class__.__name__} does not support create method")

    def update(self, instance, validated_data):
        """Do not allow updating instances from this serializer."""
        raise RuntimeError(f"{self.__class__.__name__} does not support update method")


class MessageSenderUserSerializer(serializers.ModelSerializer):
    """Lightweight serializer for the user who sent a message."""

    class Meta:
        model = models.User
        fields = ["id", "full_name", "email"]
        read_only_fields = fields


class MessageSerializer(serializers.ModelSerializer):
    """
    Serialize messages, getting parsed details from the Message model.
    Aligns field names with JMAP where appropriate (textBody, htmlBody, to, cc, bcc).
    """

    # JMAP-style body fields (from model's parsed data)
    textBody = serializers.SerializerMethodField(read_only=True)
    htmlBody = serializers.SerializerMethodField(read_only=True)
    draftBody = serializers.SerializerMethodField(read_only=True)
    attachments = serializers.SerializerMethodField(read_only=True)

    # JMAP-style recipient fields (from model's parsed data)
    to = serializers.SerializerMethodField(read_only=True)
    cc = serializers.SerializerMethodField(read_only=True)
    bcc = serializers.SerializerMethodField(read_only=True)

    sender = ContactSerializer(read_only=True)  # Sender contact info
    sender_user = MessageSenderUserSerializer(read_only=True, allow_null=True)

    # UUID of the parent message
    parent_id = serializers.UUIDField(
        source="parent.id", allow_null=True, read_only=True
    )

    # UUID of the thread
    thread_id = serializers.UUIDField(
        source="thread.id", allow_null=True, read_only=True
    )

    is_unread = serializers.SerializerMethodField(read_only=True)
    signature = serializers.SerializerMethodField()
    stmsg_headers = serializers.SerializerMethodField(read_only=True)

    @extend_schema_field(ReadMessageTemplateSerializer(allow_null=True))
    def get_signature(self, instance):
        """Return the signature template with only html_body included."""
        if instance.signature is None:
            return None
        return ReadMessageTemplateSerializer(
            instance.signature, body_fields=["html"]
        ).data

    @extend_schema_field(serializers.BooleanField())
    def get_is_unread(self, instance):
        """Return the ``_is_unread`` annotation set by ``MessageQuerySet.with_read_state()``.

        Falls back to ``False`` when the queryset was not annotated (e.g.
        internal usage without a mailbox context).
        """
        return getattr(instance, "_is_unread", False)

    def get_stmsg_headers(self, instance) -> dict:
        """Return the STMSG headers of the message."""
        return instance.get_stmsg_headers()

    @extend_schema_field(MessageBodyItemSerializer(many=True))
    def get_textBody(self, instance):  # pylint: disable=invalid-name
        """Return the list of text body parts (JMAP style)."""
        return instance.get_parsed_field("textBody") or []

    @extend_schema_field(MessageBodyItemSerializer(many=True))
    def get_htmlBody(self, instance):  # pylint: disable=invalid-name
        """Return the list of HTML body parts (JMAP style)."""
        return instance.get_parsed_field("htmlBody") or []

    @extend_schema_field(serializers.CharField(allow_null=True))
    def get_draftBody(self, instance):  # pylint: disable=invalid-name
        """Return an arbitrary JSON object representing the draft body."""
        return (
            instance.draft_blob.get_content().decode("utf-8")
            if instance.draft_blob
            else None
        )

    @extend_schema_field(AttachmentSerializer(many=True))
    def get_attachments(self, instance):
        """Return the parsed email attachments or linked attachments for drafts."""

        # If the message has no attachments, return an empty list
        if not instance.has_attachments:
            return []

        # First check for directly linked attachments (for drafts)
        if instance.is_draft:
            return AttachmentSerializer(instance.attachments.all(), many=True).data

        # Then get any parsed attachments from the email if available
        parsed_attachments = instance.get_parsed_field("attachments") or []

        # Convert parsed attachments to a format similar to AttachmentSerializer
        # Remove the content field from the parsed attachments and create a
        # reference to a virtual blob msg_[message_id]_[attachment_number]
        # This is needed to map our storage schema with the JMAP spec.
        if parsed_attachments:
            stripped_attachments = []
            for index, attachment in enumerate(parsed_attachments):
                stripped_attachments.append(
                    {
                        "blobId": f"msg_{instance.id}_{index}",
                        "name": get_attachment_display_name(
                            attachment.get("name"), attachment.get("type")
                        ),
                        "size": attachment["size"],
                        "type": attachment["type"],
                        "cid": attachment.get("cid"),
                        "sha256": attachment.get("sha256"),
                    }
                )
            return stripped_attachments

        return []

    @extend_schema_field(MessageRecipientSerializer(many=True))
    def get_to(self, instance):
        """Return the 'To' recipients."""
        recipients = models.MessageRecipient.objects.filter(
            message_id=instance.id, type=models.MessageRecipientTypeChoices.TO
        ).select_related("contact")
        return MessageRecipientSerializer(recipients, many=True).data

    @extend_schema_field(MessageRecipientSerializer(many=True))
    def get_cc(self, instance):
        """Return the 'Cc' recipients."""
        recipients = models.MessageRecipient.objects.filter(
            message_id=instance.id, type=models.MessageRecipientTypeChoices.CC
        ).select_related("contact")
        return MessageRecipientSerializer(recipients, many=True).data

    @extend_schema_field(MessageRecipientSerializer(many=True))
    def get_bcc(self, instance):
        """
        Return the 'Bcc' recipients, only if the requesting user is allowed to see them.
        """
        request = self.context.get("request")
        # Only show Bcc if it's a mailbox the user has access to and it's a sent message.
        # TODO: add some tests for this

        if (
            request
            and hasattr(request, "user")
            and request.user.is_authenticated
            and instance.is_sender
            and instance.thread.accesses.filter(
                mailbox__accesses__user=request.user,
                role=enums.ThreadAccessRoleChoices.EDITOR,
            ).exists()
        ):
            recipients = models.MessageRecipient.objects.filter(
                message_id=instance.id, type=models.MessageRecipientTypeChoices.BCC
            ).select_related("contact")
            return MessageRecipientSerializer(recipients, many=True).data

        return []

    class Meta:
        model = models.Message
        fields = [
            "id",
            "parent_id",
            "thread_id",
            "subject",
            "created_at",
            "updated_at",
            "htmlBody",
            "textBody",
            "draftBody",
            "attachments",
            "sender",
            "sender_user",
            "to",
            "cc",
            "bcc",
            "sent_at",
            "is_sender",
            "is_draft",
            "is_unread",
            "is_trashed",
            "is_archived",
            "has_attachments",
            "mime_id",
            "signature",
            "stmsg_headers",
        ]
        read_only_fields = fields  # Mark all as read-only


class ThreadAccessSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
    """Serialize thread access information."""

    role = IntegerChoicesField(choices_class=models.ThreadAccessRoleChoices)
    users = serializers.SerializerMethodField()

    class Meta:
        model = models.ThreadAccess
        fields = [
            "id",
            "thread",
            "mailbox",
            "role",
            "created_at",
            "updated_at",
            "users",
        ]
        read_only_fields = ["id", "created_at", "updated_at", "users"]
        create_only_fields = ["thread", "mailbox"]

    @extend_schema_field(UserWithoutAbilitiesSerializer(many=True))
    def get_users(self, instance):
        """Return assignable users of the mailbox.

        Scoped to the thread access because the UI renders the assignable
        users per-access. Only roles in MAILBOX_ROLES_CAN_BE_ASSIGNED are
        eligible.
        """
        accesses = [
            access
            for access in instance.mailbox.accesses.all()
            if access.role in enums.MAILBOX_ROLES_CAN_BE_ASSIGNED
        ]
        accesses.sort(key=lambda a: (a.user.full_name or "", a.user.email or ""))
        return UserWithoutAbilitiesSerializer(
            [a.user for a in accesses], many=True
        ).data


class ThreadEventSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
    """Serialize thread event information."""

    author = UserWithoutAbilitiesSerializer(read_only=True)
    author_display = serializers.SerializerMethodField()
    data = ThreadEventDataField()
    has_unread_mention = serializers.SerializerMethodField()
    is_editable = serializers.SerializerMethodField()

    class Meta:
        model = models.ThreadEvent
        fields = [
            "id",
            "thread",
            "type",
            "channel",
            "message",
            "author",
            "author_display",
            "data",
            "has_unread_mention",
            "is_editable",
            "created_at",
            "updated_at",
        ]
        read_only_fields = [
            "id",
            "thread",
            "channel",
            "author",
            "has_unread_mention",
            "is_editable",
            "created_at",
            "updated_at",
        ]
        create_only_fields = ["type", "message", "author"]

    def validate(self, attrs):
        """Validate ``data`` against the JSON Schema registered for ``type``.

        Routes through ``ThreadEvent.validate_data`` so the HTTP layer and
        ``ThreadEvent.clean()`` share a single source of truth, and so the
        client receives a field-level 400 error rather than a generic
        ValidationError bubbled up from ``full_clean()`` at save time.
        """
        # On partial updates where ``data`` is untouched, skip revalidation:
        # the stored ``data`` was already validated at creation.
        if "data" not in attrs:
            return attrs

        event_type = attrs.get("type") or getattr(self.instance, "type", None)
        try:
            models.ThreadEvent.validate_data(event_type, attrs["data"])
        except DjangoValidationError as exc:
            raise serializers.ValidationError(exc.message_dict) from exc
        return attrs

    @extend_schema_field(serializers.CharField(allow_null=True))
    def get_author_display(self, obj):
        """Human-readable author label for the UI.

        Human-authored events resolve to the user's name (or email). Events
        created by a webhook have no ``author`` but carry the firing
        ``channel``; surface it as ``Webhook: {name}`` so the timeline shows
        the integration instead of an anonymous "Unknown". Returns ``None``
        when neither is available, letting the client fall back.

        The ``Webhook:`` prefix is intentionally a fixed, server-side label
        and not localized: it is a trust/provenance marker that prevents a
        channel from choosing a ``name`` that impersonates a human author
        in the timeline. This might get moved to the frontend in the future.
        """
        if obj.author_id:
            return obj.author.full_name or obj.author.email
        if obj.channel_id and obj.channel.name:
            return f"Webhook: {obj.channel.name}"
        return None

    @extend_schema_field(serializers.BooleanField())
    def get_has_unread_mention(self, obj):
        """Return whether the event has an unread mention for the current user."""
        return getattr(obj, "_has_unread_mention", False)

    @extend_schema_field(serializers.BooleanField())
    def get_is_editable(self, obj):
        """Return whether the event is still within the edit delay window."""
        return obj.is_editable()


class MailboxAccessReadSerializer(serializers.ModelSerializer):
    """Serialize mailbox access information for read operations with nested user details.
    Mailbox context is implied by the URL, so mailbox details are not included here.
    """

    user_details = UserWithoutAbilitiesSerializer(source="user", read_only=True)
    role = IntegerChoicesField(choices_class=models.MailboxRoleChoices, read_only=True)

    class Meta:
        model = models.MailboxAccess
        fields = ["id", "user_details", "role", "created_at", "updated_at"]
        read_only_fields = fields  # All fields are effectively read-only from this serializer's perspective


class UserAccessWriteField(serializers.PrimaryKeyRelatedField):
    """Custom field that accepts either UUID or email address for user lookup.

    When an email is provided and no matching user exists, a passwordless
    "stub" user is created. The stub has no OIDC ``sub`` and will be claimed
    on first OIDC login by ``UserManager.get_user_by_sub_or_email``.
    """

    def to_internal_value(self, data):
        """Convert UUID string or email to User instance."""
        if isinstance(data, str) and "@" in data:
            user = models.User.objects.filter(email=data).first()
            if user is None:
                user = models.User(email=data)
                user.set_unusable_password()
                user.save()
            return user
        return super().to_internal_value(data)


class MailboxAccessWriteSerializer(serializers.ModelSerializer):
    """Serializer for creating and updating mailbox access records.
    Mailbox is set from the view based on URL parameters.
    """

    role = IntegerChoicesField(choices_class=models.MailboxRoleChoices)
    user = UserAccessWriteField(
        queryset=models.User.objects.all(), help_text="User ID (UUID) or email address"
    )

    class Meta:
        model = models.MailboxAccess
        fields = ["id", "user", "role", "created_at", "updated_at"]
        read_only_fields = ["id", "created_at", "updated_at"]

    def validate(self, attrs):
        """Additional validation that applies to the whole object."""
        if self.instance and "user" in attrs and attrs["user"] != self.instance.user:
            raise serializers.ValidationError(
                {
                    "user": [
                        "Cannot change the user of an existing mailbox access record. Delete and create a new one."
                    ]
                }
            )
        return attrs


class MailDomainAdminSerializer(AbilitiesModelSerializer):
    """Serialize mail domains for admin view."""

    expected_dns_records = serializers.SerializerMethodField(read_only=True)
    mailbox_count = serializers.SerializerMethodField(read_only=True)

    class Meta:
        model = models.MailDomain
        fields = [
            "id",
            "name",
            "created_at",
            "updated_at",
            "expected_dns_records",
            "mailbox_count",
            "identity_sync",
        ]
        read_only_fields = fields

    @extend_schema_field(
        {
            "type": "object",
            "description": "Instance permissions and capabilities",
            "properties": {
                choice.value: {"type": "boolean", "description": choice.label}
                for choice in [*models.CRUDAbilities, *models.MailDomainAbilities]
            },
            "required": [
                choice.value
                for choice in [*models.CRUDAbilities, *models.MailDomainAbilities]
            ],
        }
    )
    def get_abilities(self, instance):
        """Return the abilities for the mail domain."""
        return super().get_abilities(instance)

    @extend_schema_field(
        {
            "type": "array",
            "nullable": True,
            "items": {
                "type": "object",
                "properties": {
                    "target": {"type": "string"},
                    "type": {"type": "string"},
                    "value": {"type": "string"},
                },
                "required": ["target", "type", "value"],
            },
        }
    )
    def get_expected_dns_records(self, instance):
        """Return the expected DNS records for the mail domain, only in detail views."""

        # Only include DNS records in detail views, not in list views
        view = self.context.get("view")
        if view and hasattr(view, "action") and view.action == "retrieve":
            return instance.get_expected_dns_records()

        return None

    def get_mailbox_count(self, instance):
        """Return the number of mailboxes for the mail domain.

        Relies on the `mailbox_count` annotation provided by the viewset queryset
        to avoid an N+1 COUNT query per domain when listing.
        """
        annotated = getattr(instance, "mailbox_count", None)
        if annotated is not None:
            return annotated
        return instance.mailbox_set.count()


class MaildomainAccessReadSerializer(serializers.ModelSerializer):
    """
    Serialize maildomain access information for read operations with nested user details.
    """

    user = UserWithoutAbilitiesSerializer(read_only=True)
    role = IntegerChoicesField(
        choices_class=models.MailDomainAccessRoleChoices, read_only=True
    )

    class Meta:
        model = models.MailDomainAccess
        fields = ["id", "user", "role", "created_at", "updated_at"]
        read_only_fields = fields


class MaildomainAccessWriteSerializer(serializers.ModelSerializer):
    """
    Serializer for creating and updating maildomain access records.
    """

    role = IntegerChoicesField(choices_class=models.MailDomainAccessRoleChoices)
    user = UserAccessWriteField(
        queryset=models.User.objects.all(), help_text="User ID (UUID) or email address"
    )

    class Meta:
        model = models.MailDomainAccess
        fields = ["id", "user", "role", "created_at", "updated_at"]
        read_only_fields = ["id", "created_at", "updated_at"]

    def validate(self, attrs):
        """Additional validation that applies to the whole object."""
        if self.instance and "user" in attrs and attrs["user"] != self.instance.user:
            raise serializers.ValidationError(
                {
                    "user": [
                        "Cannot change the user of an existing maildomain access record. Delete and create a new one."
                    ]
                }
            )
        return attrs


class MailDomainAdminWriteSerializer(serializers.ModelSerializer):
    """Serialize mail domains for creating / editing admin view."""

    class Meta:
        model = models.MailDomain
        fields = [
            "id",
            "name",
            "created_at",
            "updated_at",
            "oidc_autojoin",
            "identity_sync",
            "custom_attributes",
        ]
        read_only_fields = ["id", "created_at", "updated_at"]


class MailboxAccessNestedUserSerializer(serializers.ModelSerializer):
    """
    Serialize MailboxAccess for nesting within MailboxAdminSerializer.
    Shows user details and their role on the mailbox.
    """

    user = UserWithoutAbilitiesSerializer(read_only=True)
    role = IntegerChoicesField(choices_class=models.MailboxRoleChoices, read_only=True)

    class Meta:
        model = models.MailboxAccess
        fields = ["id", "user", "role"]  # 'user' will be nested UserSerializer output
        read_only_fields = fields


class MailboxAdminSerializer(serializers.ModelSerializer):
    """
    Serialize Mailbox details for admin view, including users with access.
    """

    domain_name = serializers.CharField(source="domain.name", read_only=True)
    accesses = MailboxAccessNestedUserSerializer(
        many=True, read_only=True
    )  # accesses is the related_name
    can_reset_password = serializers.BooleanField(read_only=True)
    # ``Mailbox.contact`` is ``SET_NULL, null=True`` — an alias mailbox (or one
    # whose contact was deleted) has none, so the nested field must be nullable.
    contact = ContactSerializer(read_only=True, allow_null=True)
    alias_of = serializers.PrimaryKeyRelatedField(
        required=False, allow_null=True, queryset=models.Mailbox.objects.none()
    )
    last_accessed_at = serializers.SerializerMethodField(
        help_text="Most recent ``accessed_at`` across all mailbox accesses."
    )
    has_mandatory_totp = serializers.SerializerMethodField(
        help_text=(
            "Whether the Keycloak user backing this mailbox carries the "
            "KEYCLOAK_TOTP_ROLE_ID realm role. ``null`` when the feature is "
            "disabled or the role id isn't configured."
        )
    )

    @extend_schema_field(serializers.DateTimeField(allow_null=True))
    def get_last_accessed_at(self, obj):
        """Prefers the ``last_accessed_at`` annotation set by the admin list
        viewset (single SQL aggregate). Falls back to a per-instance query so
        the field still resolves on create/retrieve/update responses where the
        annotation isn't present."""
        if hasattr(obj, "last_accessed_at"):
            return obj.last_accessed_at
        return obj.accesses.aggregate(value=Max("accessed_at"))["value"]

    @extend_schema_field(serializers.BooleanField(allow_null=True))
    def get_has_mandatory_totp(self, obj):
        """Whether the mailbox user has the configured TOTP realm role.

        Returns ``None`` when the feature is disabled, prerequisites aren't
        met (no role configured, identity sync off, not a personal mailbox),
        or the serializer is rendering outside the admin list flow — the
        field is only populated when the viewset pre-resolves it for the
        whole page (one pipelined Redis call) and stashes the result in
        ``context["mandatory_totp_membership"]``.
        """
        if (
            not keycloak_service.is_mandatory_totp_enabled()
            or not obj.is_identity
            or not obj.domain.identity_sync
        ):
            return None

        membership = self.context.get("mandatory_totp_membership")
        if membership is None:
            return None
        return membership.get(str(obj))

    class Meta:
        model = models.Mailbox
        fields = [
            "id",
            "local_part",
            "domain_name",
            "is_identity",
            "alias_of",  # show if it's an alias
            "accesses",  # List of users and their roles
            "created_at",
            "updated_at",
            "can_reset_password",
            "contact",
            "last_accessed_at",
            "has_mandatory_totp",
        ]
        read_only_fields = [
            "id",
            "domain_name",
            "is_identity",
            "accesses",  # List of users and their roles
            "created_at",
            "updated_at",
            "can_reset_password",
            "contact",
            "last_accessed_at",
            "has_mandatory_totp",
        ]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        if self.context.get("domain"):
            # Lookup in domain mailboxes that are not an alias
            # We must do that here to define a finer grain queryset to lookup
            self.fields["alias_of"].queryset = models.Mailbox.objects.filter(
                domain=self.context.get("domain"), alias_of__isnull=True
            )

    def validate(self, attrs):
        """Validate the domain of the mailbox and denylist rules."""
        if not self.context.get("domain"):
            raise serializers.ValidationError(
                "Domain is required in serializer context."
            )

        metadata = self.context.get("metadata", {})

        # Personal mailboxes can be created even when identity synchronization is
        # disabled: this lets admins pre-create mailboxes for users who connect
        # through a third-party (non-synced) identity provider. No password is
        # provisioned in that case (see Mailbox.can_reset_password), but the
        # mailbox can receive emails straight away.

        if metadata.get("type") == "personal":
            local_part = attrs.get("local_part", "")
            denylist = settings.MESSAGES_MAILBOX_LOCALPART_DENYLIST_PERSONAL
            lower_value = local_part.lower()
            if any(lower_value == prefix.lower() for prefix in denylist):
                raise serializers.ValidationError(
                    {
                        "local_part_denied": (
                            "This prefix is not allowed for personal mailboxes."
                        )
                    }
                )

        return super().validate(attrs)

    def validate_local_part(self, value):
        """Validate the local part of the mailbox."""
        if models.Mailbox.objects.filter(
            domain=self.context.get("domain"), local_part=value
        ).exists():
            raise serializers.ValidationError(
                "A mailbox with this local part already exists in this domain."
            )
        return value

    def create(self, validated_data):
        """Perform the create action."""
        domain = self.context.get("domain")
        metadata = self.context.get("metadata", {})
        mailbox_type = metadata.get("type")

        mailbox = models.Mailbox.objects.create(
            domain=domain,
            local_part=validated_data.get("local_part"),
            alias_of=validated_data.get("alias_of"),
            is_identity=mailbox_type == "personal",
        )

        if mailbox_type == "personal":
            email = str(mailbox)
            first_name = metadata.get("first_name")
            last_name = metadata.get("last_name")
            custom_attributes = metadata.get("custom_attributes", {})
            user, created = models.User.objects.get_or_create(
                email=email,
                defaults={
                    "custom_attributes": custom_attributes,
                    "full_name": f"{first_name} {last_name}",
                    "password": "?",
                },
            )

            if not created and custom_attributes:
                user.custom_attributes = custom_attributes
                user.save()

            models.MailboxAccess.objects.create(
                mailbox=mailbox,
                user=user,
                role=models.MailboxRoleChoices.ADMIN,
            )

            contact, _ = models.Contact.objects.get_or_create(
                email=email,
                mailbox=mailbox,
                defaults={"name": f"{first_name} {last_name}"},
            )
            mailbox.contact = contact
            mailbox.save()

        elif mailbox_type == "shared":
            email = str(mailbox)
            name = metadata.get("name")
            contact, _ = models.Contact.objects.get_or_create(
                email=email,
                mailbox=mailbox,
                defaults={"name": name},
            )
            mailbox.contact = contact
            mailbox.save()

        return mailbox

    def update(self, instance, validated_data):
        """Perform the update action."""
        # Do not allow to update some mailbox fields
        validated_data.pop("local_part", None)
        validated_data.pop("alias_of", None)
        validated_data.pop("is_identity", None)

        metadata = self.context.get("metadata", {})
        updated = False

        if instance.is_identity is True:
            user_updated_fields = {}
            display_name = metadata.get("full_name")

            if display_name:
                user_updated_fields["full_name"] = display_name
            if custom_attributes := metadata.get("custom_attributes"):
                user_updated_fields["custom_attributes"] = custom_attributes

            if user_updated_fields:
                owner = models.User.objects.filter(
                    email=str(instance), mailbox_accesses__mailbox=instance
                ).first()
                # Use save here to enforce data validation on custom_attributes
                if owner:
                    for key, value in user_updated_fields.items():
                        setattr(owner, key, value)
                    owner.save(update_fields=list(user_updated_fields.keys()))
                    updated = True

        else:
            display_name = metadata.get("name")

        if display_name:
            instance.set_display_name(display_name)
            updated = True

        if updated:
            instance.refresh_from_db()

        return instance


class MailboxAdminCreateSerializer(MailboxAdminSerializer):
    """
    Serialize Mailbox details for create admin endpoint, including users with access and
    metadata.
    """

    one_time_password = serializers.SerializerMethodField(
        read_only=True, required=False
    )

    def get_one_time_password(self, instance) -> str | None:
        """
        Fake method just to make the OpenAPI schema valid.
        """

    class Meta:
        model = models.Mailbox
        fields = MailboxAdminSerializer.Meta.fields + ["one_time_password"]
        read_only_fields = fields


class ImportBaseSerializer(serializers.Serializer):
    """Base serializer for import actions that disables create and update."""

    def create(self, validated_data):
        """Do not allow creating instances from this serializer."""
        raise RuntimeError(f"{self.__class__.__name__} does not support create method")

    def update(self, instance, validated_data):
        """Do not allow updating instances from this serializer."""
        raise RuntimeError(f"{self.__class__.__name__} does not support update method")


class ImportFileUploadSerializer(ImportBaseSerializer):
    """Serializer for uploading files to the message imports bucket."""

    filename = serializers.CharField(
        help_text="Filename",
        required=True,
    )
    content_type = serializers.CharField(
        help_text="Content type",
        required=True,
    )

    class Meta:
        fields = ["filename", "content_type"]

    def validate_content_type(self, value):
        """Validate content type."""
        if value not in enums.ARCHIVE_SUPPORTED_MIME_TYPES:
            raise serializers.ValidationError(
                "Only EML, MBOX, and PST files are supported."
            )
        return value


class ImportFileUploadPartSerializer(ImportBaseSerializer):
    """Serializer for uploading parts of a file to the message imports bucket."""

    file_key = serializers.CharField(
        help_text="Storage key returned by the upload-create call.",
        required=True,
    )
    upload_id = serializers.CharField(
        help_text="Upload ID",
        required=True,
    )
    part_number = serializers.IntegerField(
        help_text="Part number", required=True, min_value=1
    )

    class Meta:
        fields = ["file_key", "upload_id", "part_number"]


class UploadPartSerializer(ImportBaseSerializer):
    """Serializer for an upload part."""

    ETag = serializers.CharField(
        help_text="ETag",
        required=True,
    )
    PartNumber = serializers.IntegerField(
        help_text="Part number", required=True, min_value=1
    )

    class Meta:
        fields = ["ETag", "PartNumber"]


class ImportFileUploadCompleteSerializer(ImportBaseSerializer):
    """Serializer for completing a multipart upload of a file to the message imports bucket."""

    file_key = serializers.CharField(
        help_text="Storage key returned by the upload-create call.",
        required=True,
    )
    upload_id = serializers.CharField(
        help_text="Upload ID",
        required=True,
    )
    parts = UploadPartSerializer(required=True, many=True)

    class Meta:
        fields = ["file_key", "upload_id", "parts"]


class ImportFileUploadAbortSerializer(ImportBaseSerializer):
    """Serializer for aborting a multipart upload of a file to the message imports bucket."""

    file_key = serializers.CharField(
        help_text="Storage key returned by the upload-create call.",
        required=True,
    )
    upload_id = serializers.CharField(
        help_text="Upload ID",
        required=True,
    )

    class Meta:
        fields = ["file_key", "upload_id"]


class ImportCreateSerializer(serializers.Serializer):  # pylint: disable=abstract-method
    """Body of ``POST /mailboxes/{id}/imports/`` — starts a file or IMAP import.

    The target mailbox comes from the URL. ``source`` discriminates: ``file``
    needs the ``file_key`` returned by the upload endpoint (unique per upload —
    nothing in the bucket is ever overwritten) plus the original ``filename``
    (display + MIME-type hint); ``imap`` needs the connection fields.
    ``continuous`` mode is IMAP-only. Read-only run state is returned via
    ``ImportRunSerializer``.
    """

    source = serializers.ChoiceField(
        choices=["file", "imap"],
        help_text=(
            "'file' (an uploaded archive — the backend sniffs eml/mbox/pst) or "
            "'imap' (a live server)."
        ),
    )
    # file source
    file_key = serializers.CharField(
        required=False,
        help_text="Storage key returned when the archive was uploaded.",
    )
    filename = serializers.CharField(
        required=False,
        help_text="Original archive filename (display + MIME-type hint).",
    )
    # imap source
    imap_server = serializers.CharField(required=False)
    # Kept deliberately flexible (any valid TCP port, not just 143/993) so
    # non-standard IMAP deployments work — but bounded to a real port range.
    imap_port = serializers.IntegerField(required=False, min_value=1, max_value=65535)
    # CharField, not EmailField: an IMAP username is a login (often but not
    # always an email). (Also sidesteps a platform EmailValidator regression on
    # Python 3.14 that rejects all addresses.)
    username = serializers.CharField(required=False)
    password = serializers.CharField(required=False, write_only=True)
    use_ssl = serializers.BooleanField(required=False, default=True)
    mode = serializers.ChoiceField(
        choices=[m.value for m in enums.ImportMode],
        required=False,
        default=enums.ImportMode.ONESHOT.value,
    )

    def validate(self, attrs):
        source = attrs.get("source")
        if source == "file":
            missing = [
                f for f in ("file_key", "filename") if attrs.get(f) in (None, "")
            ]
            if missing:
                raise serializers.ValidationError(
                    dict.fromkeys(missing, "Required for a file import.")
                )
            # File imports are always oneshot — continuous polling only makes
            # sense against a live IMAP server.
            if attrs.get("mode") == enums.ImportMode.CONTINUOUS.value:
                raise serializers.ValidationError(
                    {"mode": "Continuous mode is only supported for IMAP imports."}
                )
        elif source == "imap":
            # ``in (None, "")`` rather than a falsy check so a numeric field like
            # imap_port isn't misreported as "missing" for an out-of-range 0
            # (the field validator already rejects 0 with a clearer message).
            missing = [
                f
                for f in ("imap_server", "imap_port", "username", "password")
                if attrs.get(f) in (None, "")
            ]
            if missing:
                raise serializers.ValidationError(
                    dict.fromkeys(missing, "Required for an IMAP import.")
                )
        return attrs


class ImportRunSerializer(serializers.ModelSerializer):
    """Read-only view of an import run (a ``Channel`` with type=import).

    Progress lives in Redis and terminal state on the channel; the serializer
    reads the merged view so the frontend can poll the import resource
    (``GET /mailboxes/{id}/imports/{id}/``) instead of the raw Celery task state.
    """

    status = serializers.SerializerMethodField()
    source_type = serializers.SerializerMethodField()
    mode = serializers.SerializerMethodField()
    poll_interval = serializers.SerializerMethodField()
    imap_username = serializers.SerializerMethodField()
    total_messages = serializers.SerializerMethodField()
    success_count = serializers.SerializerMethodField()
    failure_count = serializers.SerializerMethodField()
    failure_reasons = serializers.SerializerMethodField()
    progress = serializers.SerializerMethodField()
    error = serializers.SerializerMethodField()
    started_at = serializers.SerializerMethodField()
    finished_at = serializers.SerializerMethodField()

    class Meta:
        model = models.Channel
        fields = [
            "id",
            "name",
            "mailbox",
            "is_active",
            "status",
            "source_type",
            "mode",
            "poll_interval",
            "imap_username",
            "total_messages",
            "success_count",
            "failure_count",
            "failure_reasons",
            "progress",
            "error",
            "started_at",
            "finished_at",
            "last_used_at",
            "created_at",
            "updated_at",
        ]
        read_only_fields = fields

    @staticmethod
    def _run(obj):
        # One Redis read per serialized object, not one per field: the imports
        # list is polled every 1-2s by the grid and the header indicator while
        # a run is live, so the ~11 method fields must share one merged_state.
        # Stashed on the row instance (not the serializer) because ``many=True``
        # reuses a single child serializer for every row.
        cached = getattr(obj, "_merged_import_state", None)
        if cached is None:
            cached = merged_state(obj)
            obj._merged_import_state = cached  # noqa: SLF001  # pylint: disable=protected-access
        return cached

    @extend_schema_field(serializers.CharField(allow_null=True))
    def get_status(self, obj):
        """Live run status (pending/running/completed/failed/cancelled)."""
        return self._run(obj).get("status")

    @extend_schema_field(serializers.CharField(allow_null=True))
    def get_source_type(self, obj):
        """Import source: mbox/eml/pst/imap."""
        return self._run(obj).get("source_type")

    @extend_schema_field(serializers.CharField(allow_null=True))
    def get_mode(self, obj):
        """Run mode: oneshot or continuous."""
        return self._run(obj).get("mode")

    @extend_schema_field(serializers.IntegerField(allow_null=True))
    def get_poll_interval(self, obj):
        """Poll cadence (seconds) — only for a continuous (polling) IMAP run.

        The cadence is a global operator setting, not stored per-import; only a
        continuous run actually polls, so surface it just then.
        """
        if self._run(obj).get("mode") != enums.ImportMode.CONTINUOUS.value:
            return None
        return settings.MESSAGES_IMPORT_IMAP_POLL_INTERVAL

    @extend_schema_field(serializers.CharField(allow_null=True))
    def get_imap_username(self, obj):
        """The IMAP login shown in the imports UI (``None`` for file imports).

        Only the (non-secret) login is ever exposed — never the rest of the
        stored credentials.
        """
        return ((obj.encrypted_settings or {}).get("imap") or {}).get("username")

    @extend_schema_field(serializers.IntegerField())
    def get_total_messages(self, obj):
        """Total messages the run expects to process."""
        return self._run(obj).get("total") or 0

    @extend_schema_field(serializers.IntegerField())
    def get_success_count(self, obj):
        """Messages delivered so far."""
        return self._run(obj).get("success") or 0

    @extend_schema_field(serializers.IntegerField())
    def get_failure_count(self, obj):
        """Messages that failed so far."""
        return self._run(obj).get("failure") or 0

    @extend_schema_field(
        serializers.DictField(child=serializers.IntegerField(), allow_null=True)
    )
    def get_failure_reasons(self, obj):
        """Breakdown of the failures by category (``{reason: count}``).

        Lets the UI turn a bare "N failed" into an explanation (too large,
        unreadable, …). ``None``/empty when nothing failed.
        """
        return self._run(obj).get("failure_reasons") or None

    @extend_schema_field(serializers.FloatField())
    def get_progress(self, obj):
        """Percentage of processed (success+failure) over total, 0-100."""
        run = self._run(obj)
        total = run.get("total") or 0
        if not total:
            return 0.0
        done = (run.get("success") or 0) + (run.get("failure") or 0)
        return round(min(done, total) / total * 100, 1)

    @extend_schema_field(serializers.CharField(allow_null=True))
    def get_error(self, obj):
        """The run's error message, if it failed."""
        return self._run(obj).get("error")

    @extend_schema_field(serializers.CharField(allow_null=True))
    def get_started_at(self, obj):
        """ISO timestamp the run started, or ``None``."""
        return self._run(obj).get("started_at")

    @extend_schema_field(serializers.CharField(allow_null=True))
    def get_finished_at(self, obj):
        """ISO timestamp the run finished, or ``None``."""
        return self._run(obj).get("finished_at")


class ImportUpdateSerializer(serializers.Serializer):  # pylint: disable=abstract-method
    """Body of ``PATCH /mailboxes/{id}/imports/{id}/`` — change how a run runs.

    Both fields optional (partial). ``mode=continuous`` (re-)arms an IMAP import
    as a poller; ``mode=oneshot`` demotes a continuous poller (stops polling);
    ``is_active=false`` pauses a continuous poller (credentials are kept). The
    poll cadence itself is a global operator setting, not settable here.
    Validation is source-aware: only IMAP imports can go continuous.
    """

    mode = serializers.ChoiceField(
        choices=[m.value for m in enums.ImportMode], required=False
    )
    is_active = serializers.BooleanField(required=False)

    def validate(self, attrs):
        if not attrs:
            raise serializers.ValidationError(
                "Provide at least one of 'mode' or 'is_active'."
            )
        run = (self.instance.settings or {}).get("import", {})
        source_type = run.get("source_type")
        current_mode = run.get("mode")
        arming_continuous = attrs.get("mode") == enums.ImportMode.CONTINUOUS.value
        going_continuous = arming_continuous or (
            attrs.get("is_active") is True
            and current_mode == enums.ImportMode.CONTINUOUS.value
        )
        if going_continuous and source_type != enums.ImportSource.IMAP.value:
            raise serializers.ValidationError(
                {"mode": "Only IMAP imports can run continuously."}
            )
        # mode=oneshot means "stop polling", is_active=true means "poll" —
        # reject the contradiction rather than picking one silently.
        if (
            attrs.get("mode") == enums.ImportMode.ONESHOT.value
            and attrs.get("is_active") is True
        ):
            raise serializers.ValidationError(
                {"mode": "mode=oneshot cannot be combined with is_active=true."}
            )
        # ``is_active=true`` only means "re-arm the poller". On a one-shot import
        # (which just runs once and finishes) there is nothing to re-activate, so
        # reject it rather than silently no-op — the caller must set
        # ``mode=continuous`` to turn it into a poller.
        if (
            attrs.get("is_active") is True
            and not arming_continuous
            and current_mode != enums.ImportMode.CONTINUOUS.value
        ):
            raise serializers.ValidationError(
                {
                    "is_active": (
                        "A one-shot import cannot be re-activated; set "
                        "mode=continuous to re-enable it as a poller."
                    )
                }
            )
        # ``is_active=false`` only means "pause the poller". On a one-shot it
        # is meaningless — and actively harmful on a running one: it would
        # disable the crash-recovery reaper, and with re-activation rejected
        # above the run would be stranded "running" forever (the only exit
        # being a cancel that deletes the delivered mail). The arm-paused
        # combination (mode=continuous + is_active=false) stays allowed.
        if (
            attrs.get("is_active") is False
            and not arming_continuous
            and current_mode != enums.ImportMode.CONTINUOUS.value
        ):
            raise serializers.ValidationError(
                {"is_active": "Only a continuous import can be paused."}
            )
        return attrs


class ChannelSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
    """Serialize Channel model."""

    # Explicitly mark nullable fields to fix OpenAPI schema
    mailbox = serializers.PrimaryKeyRelatedField(read_only=True, allow_null=True)
    maildomain = serializers.PrimaryKeyRelatedField(read_only=True, allow_null=True)
    user = serializers.PrimaryKeyRelatedField(read_only=True, allow_null=True)
    scope_level = serializers.ChoiceField(
        choices=enums.ChannelScopeLevel.choices, read_only=True
    )
    last_used_at = serializers.DateTimeField(read_only=True, allow_null=True)
    token_hash = serializers.SerializerMethodField()

    class Meta:
        model = models.Channel
        fields = [
            "id",
            "name",
            "type",
            "scope_level",
            "is_active",
            "settings",
            "mailbox",
            "maildomain",
            "user",
            "last_used_at",
            "token_hash",
            "created_at",
            "updated_at",
        ]
        read_only_fields = [
            "id",
            "mailbox",
            "maildomain",
            "user",
            "scope_level",
            "last_used_at",
            "token_hash",
            "created_at",
            "updated_at",
        ]
        # ``type`` is writable on CREATE only — once a channel exists its
        # type is frozen. PATCHing type=widget on an api_key channel
        # would let a mailbox admin sneak around the create-time scope
        # check (the new type wouldn't trip _validate_api_key_scopes) or
        # strand encrypted_settings on a row whose type the auth class
        # no longer recognizes. Lock it down via CreateOnlyFieldsMixin.
        create_only_fields = ["type"]

    # Per-type list of settings keys that are server-managed and must
    # NEVER appear in caller-supplied ``settings``. Used by
    # ``_reject_caller_supplied_encrypted_keys`` below — there is no
    # automatic move-from-settings-to-encrypted shim, server-side
    # generators write directly to ``encrypted_settings`` instead.
    RESERVED_SETTINGS_KEYS = {
        enums.ChannelTypes.API_KEY: ["api_key_hashes"],
        # CalDAV credentials must live in ``encrypted_settings``, never in
        # the plaintext ``settings`` JSONField — a DB read would otherwise
        # surface every user's CalDAV password.
        enums.ChannelTypes.CALDAV: ["username", "password"],
        # The outgoing webhook secret is generated server-side and
        # written straight to ``encrypted_settings["secret"]``; callers
        # must never smuggle their own value in via ``settings``. The
        # same bytes back both auth methods (JWT/HMAC and API key) —
        # see ``Channel.rotate_secret``.
        enums.ChannelTypes.WEBHOOK: ["secret"],
    }

    # Channel types that mint a plaintext secret on create. The
    # viewset surfaces the minted value via the right response key
    # (``api_key`` for api_key channels and api_key webhooks, ``secret``
    # for jwt webhooks — keyed by ``settings.auth_method``).
    _ROTATABLE_TYPES = frozenset(
        {enums.ChannelTypes.API_KEY, enums.ChannelTypes.WEBHOOK}
    )

    @extend_schema_field(serializers.CharField(allow_null=True))
    def get_token_hash(self, obj):
        """Expose the push channel's ``lookup_hash`` (sha256 of the device
        token/endpoint) so the web client can recognise *its own* device row —
        needed to unsubscribe this browser on sign-out — without the raw
        token/endpoint ever leaving the server. The hash is preimage-resistant,
        so it discloses nothing replayable (the cross-user reclaim path already
        requires the raw token; see ``services.push.common.register_push_device``).
        Only ``push`` channels populate ``lookup_hash``; every other type
        returns ``None``.
        """
        if obj.type == enums.ChannelTypes.PUSH:
            return obj.lookup_hash
        return None

    def create(self, validated_data):
        # Mint the per-type secret on a transient instance so the
        # resulting ``encrypted_settings`` rides through the normal
        # ``super().create()`` save path. The plaintext (when one
        # exists at rest — only api_key channels store it indirectly
        # via a hash) is stashed on the saved row for
        # ``ChannelViewSet.create()`` to surface exactly once.
        plaintext = None
        if validated_data.get("type") in self._ROTATABLE_TYPES:
            transient = models.Channel(**validated_data)
            plaintext = transient.rotate_secret(save=False)
            validated_data["encrypted_settings"] = transient.encrypted_settings

        instance = super().create(validated_data)

        # pylint: disable=protected-access
        if plaintext is not None and instance.type == enums.ChannelTypes.API_KEY:
            instance._generated_api_key = plaintext  # noqa: SLF001
        return instance

    def validate_settings(self, value):
        """Validate settings, including tags if present."""
        if not value:
            return value

        tags = value.get("tags", [])
        if not tags:
            return value

        # Get mailbox from context or instance
        mailbox = self.context.get("mailbox")
        if not mailbox and self.instance:
            mailbox = self.instance.mailbox

        if not mailbox:
            # Tags require a mailbox - can't use tags without one
            raise serializers.ValidationError(
                {"tags": "Tags can only be used when a mailbox is configured."}
            )

        # Validate each tag
        invalid_tags = []
        missing_tags = []

        for tag_id in tags:
            # Validate UUID format
            try:
                tag_uuid = uuid.UUID(str(tag_id))
            except (ValueError, TypeError):
                invalid_tags.append(tag_id)
                continue

            # Check if label exists in the mailbox
            if not models.Label.objects.filter(id=tag_uuid, mailbox=mailbox).exists():
                missing_tags.append(tag_id)

        errors = []
        if invalid_tags:
            errors.append(f"Invalid tag IDs (not valid UUIDs): {invalid_tags}")
        if missing_tags:
            errors.append(f"Tags not found in mailbox: {missing_tags}")

        if errors:
            raise serializers.ValidationError({"tags": errors})

        return value

    def _reject_caller_supplied_encrypted_keys(self, attrs):
        """Refuse any request that puts ``RESERVED_SETTINGS_KEYS`` values
        in ``settings``.

        ``encrypted_settings`` itself is not in ``ChannelSerializer.fields``,
        so callers can't write it directly. Reserved settings keys (e.g.
        ``api_key_hashes``) are written by server-side generators and
        must never originate from a request body. Without this check, a
        mailbox admin could PATCH ``{"settings": {"scopes": [...],
        "api_key_hashes": [<chosen>]}}`` and the row's settings JSON
        would carry the attacker-chosen value alongside the legitimate
        ones — confusing, even when not directly authenticatable.
        """
        settings_data = attrs.get("settings")
        if not isinstance(settings_data, dict):
            return
        # Use the resolved type so this works on both CREATE and PATCH.
        channel_type = attrs.get("type") or (
            self.instance.type if self.instance else None
        )
        reserved = self.RESERVED_SETTINGS_KEYS.get(channel_type, [])
        if any(k in settings_data for k in reserved):
            raise serializers.ValidationError({"settings": "Invalid settings."})

    def _resolve_type_and_settings(self, attrs):
        """Compute (channel_type, settings_data, should_validate) for a
        type-specific validator. The pair handles all three lifecycle paths:

          - CREATE: ``type`` and ``settings`` come from ``attrs``.
          - PATCH/PUT touching settings (or type): ``settings`` overrides
            the instance's existing JSON; ``type`` falls back to the
            instance.
          - PATCH that doesn't touch ``type`` or ``settings`` (e.g. a
            rename): the validator is skipped because the field being
            validated isn't in play. ``should_validate`` is False.

        This is the central fix for the PATCH-bypass escalation: the old
        code returned early when ``attrs.get("type")`` was None, which is
        the typical PATCH shape, allowing a mailbox admin to PATCH
        ``{"settings": {"scopes": ["maildomains:create"]}}`` and have it
        slip through unvalidated.
        """
        instance = self.instance
        channel_type = attrs.get("type") or (instance.type if instance else None)
        settings_in_attrs = "settings" in attrs

        # If neither type nor settings are being introduced/changed, this
        # validator has nothing to look at.
        if not settings_in_attrs and "type" not in attrs:
            return channel_type, None, False

        if settings_in_attrs:
            settings_data = attrs.get("settings") or {}
        elif instance is not None:
            # type was changed but settings weren't — fall back to the
            # instance's existing settings so we validate the post-save
            # combination.
            settings_data = instance.settings or {}
        else:
            settings_data = {}

        return channel_type, settings_data, True

    def _validate_api_key_scopes(self, attrs):
        """Validate the ``settings["scopes"]`` list on api_key channels.

        Runs on every CREATE and on every PATCH/PUT that touches ``type``
        or ``settings``. The validator looks at the EFFECTIVE post-save
        state — explicit attrs win, instance state is the fallback —
        which is the only way to airtight-block a mailbox admin from
        granting themselves a global-only scope via a settings-only PATCH.

        - Every value must be a member of ``ChannelApiKeyScope``.
        - Global-only scopes (e.g. ``maildomains:create``) can only be
          requested when the channel itself has ``scope_level=global``.
          Since DRF clients cannot set scope_level, this always rejects
          global-only scopes on the mailbox-nested and user-nested paths.
        """
        channel_type, settings_data, should_validate = self._resolve_type_and_settings(
            attrs
        )
        if not should_validate or channel_type != enums.ChannelTypes.API_KEY:
            return

        raw_scopes = settings_data.get("scopes")
        if raw_scopes is None:
            raise serializers.ValidationError(
                {
                    "settings": "api_key channels require settings.scopes (a list of strings)."
                }
            )
        if not isinstance(raw_scopes, list) or not all(
            isinstance(s, str) for s in raw_scopes
        ):
            raise serializers.ValidationError(
                {"settings": "settings.scopes must be a list of strings."}
            )

        valid_values = {choice.value for choice in enums.ChannelApiKeyScope}
        unknown = [s for s in raw_scopes if s not in valid_values]
        if unknown:
            raise serializers.ValidationError(
                {"settings": f"Unknown api_key scopes: {unknown}"}
            )

        # Any DRF path to this serializer is mailbox-nested or user-nested
        # today, so the effective scope_level on save will be MAILBOX or
        # USER. Global-only scopes are therefore never grantable via DRF.
        # If that ever changes, the viewset must still pass scope_level
        # explicitly on save.
        global_only = enums.CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY
        if any(s in global_only for s in raw_scopes):
            raise serializers.ValidationError(
                {"settings": "One or more requested scopes are not permitted."}
            )

    def _validate_webhook_settings(self, attrs):
        """Validate required fields on webhook channel settings.

        Runs on CREATE and on every PATCH/PUT touching ``type`` or
        ``settings``. Same airtight rule as ``_validate_api_key_scopes``:
        a settings-only PATCH on an existing webhook channel must hit the
        URL/trigger validators, otherwise a mailbox admin could PATCH
        ``{"settings": {"url": "javascript:..."}}`` past the create-time
        check.
        """
        channel_type, settings_data, should_validate = self._resolve_type_and_settings(
            attrs
        )
        if not should_validate or channel_type != enums.ChannelTypes.WEBHOOK:
            return

        url = settings_data.get("url")
        if not url or not isinstance(url, str):
            raise serializers.ValidationError(
                {"settings": "webhook channels require settings.url (a string)."}
            )
        # Parse first so the scheme check sees urlparse's normalized
        # (lowercased) scheme — a raw ``startswith`` would wrongly reject
        # e.g. ``HTTPS://``.
        parsed_url = urlparse(url)
        if parsed_url.scheme not in ("http", "https"):
            raise serializers.ValidationError(
                {"settings": "webhook settings.url must be http:// or https://"}
            )
        host = (parsed_url.hostname or "").lower()
        if not host:
            raise serializers.ValidationError(
                {"settings": "webhook settings.url must include a host."}
            )
        # Each POST carries the HMAC/JWT signing material in its headers,
        # so plaintext http would leak credentials in transit. Require
        # https except under DEBUG, the local-dev escape hatch where
        # operators routinely point at a local receiver. We deliberately
        # do *not* special-case loopback hosts here: in production the
        # shared SSRF guard (``services.ssrf``) rejects loopback targets
        # at dispatch anyway, so a hand-rolled allowance would only ever
        # apply under DEBUG — which the check below already covers.
        if parsed_url.scheme != "https" and not settings.DEBUG:
            raise serializers.ValidationError(
                {"settings": "webhook settings.url must use https://."}
            )

        # Reject internal / unresolvable webhook URLs up front, so a misconfig
        # is a clear 400 at config time instead of silently-stalled mail later.
        # This is a fail-fast UX layer, NOT the security boundary: a hostname
        # can resolve public now and be rebound to internal at dispatch, so
        # ``SSRFSafeSession`` re-validates with IP pinning on every POST
        # regardless. ``validate_hostname`` does a DNS lookup (a config
        # endpoint, not a hot path) and rejects IP literals, private/internal
        # targets, and names that don't resolve. Skipped under DEBUG, the
        # local-dev escape hatch where operators point at local receivers.
        if not settings.DEBUG:
            try:
                validate_hostname(host)
            except SSRFValidationError as exc:
                raise serializers.ValidationError(
                    {"settings": f"webhook settings.url is not allowed: {exc}"}
                ) from exc

        # A single ``trigger`` lifecycle event describes the webhook — it
        # encodes the event, the pipeline phase, and whether it blocks
        # delivery, so invalid combinations (e.g. non-blocking before-spam)
        # can't be expressed (see ``enums.WebhookTrigger``).
        trigger = settings_data.get("trigger")
        if trigger not in enums.WebhookTrigger:
            raise serializers.ValidationError(
                {
                    "settings": (
                        "webhook channels require settings.trigger, one of: "
                        f"{', '.join(t.value for t in enums.WebhookTrigger)}."
                    )
                }
            )

        body_format = settings_data.get("format", "eml")
        if body_format not in VALID_FORMATS:
            raise serializers.ValidationError(
                {
                    "settings": (
                        f"webhook settings.format must be one of: "
                        f"{', '.join(sorted(VALID_FORMATS))}."
                    )
                }
            )
        # auth_method selects how the stored secret is presented on each
        # POST; it pairs with the credential in encrypted_settings, which a
        # settings PATCH never touches. So treat it like that credential:
        # carry the existing value forward when a partial PATCH omits it,
        # instead of forcing the caller to re-send it on every edit (and
        # silently dropping it on the replace-on-PATCH save). Only a value
        # that is actually supplied — or a create with none — is rejected.
        auth_method = settings_data.get("auth_method")
        if auth_method is None and self.instance is not None:
            existing = (self.instance.settings or {}).get("auth_method")
            if existing in enums.WebhookAuthMethod and isinstance(
                attrs.get("settings"), dict
            ):
                # Persist it through the replace-save so the dispatcher
                # still knows how to present the secret.
                attrs["settings"]["auth_method"] = existing
                auth_method = existing
        if auth_method not in enums.WebhookAuthMethod:
            raise serializers.ValidationError(
                {
                    "settings": (
                        "webhook settings.auth_method is required, one of: "
                        f"{', '.join(m.value for m in enums.WebhookAuthMethod)}."
                    )
                }
            )

    def validate(self, attrs):
        """Validate channel data.

        When used in the nested mailbox context (via ChannelViewSet) or the
        user context (via UserChannelViewSet), the target FK is set by the
        viewset and doesn't need to be validated here. Otherwise this is
        the Django-admin / management-command path which still requires an
        explicit mailbox or maildomain.
        """
        if self.context.get("mailbox") or self.context.get("user_channel"):
            # On CREATE, ``type`` MUST be supplied explicitly: a default
            # would let a caller omit ``type`` from the body and slip past
            # FEATURE_MAILBOX_ADMIN_CHANNELS even when that default type
            # isn't in the allowlist. On UPDATE, ``type`` is made read-only
            # by ``CreateOnlyFieldsMixin`` so it's never in ``attrs`` — fall
            # through to the other validators which read the instance's
            # existing type.
            if self.instance is None and "type" not in attrs:
                raise serializers.ValidationError(
                    {
                        "type": (
                            "Channel type is required for mailbox/user "
                            "channel creation."
                        )
                    }
                )
            channel_type = attrs.get("type")
            if channel_type:
                allowed_types = list(settings.FEATURE_MAILBOX_ADMIN_CHANNELS)
                if channel_type not in allowed_types:
                    raise serializers.ValidationError(
                        {
                            "type": f"Channel type '{channel_type}' is not authorized. "
                            f"Allowed types: {', '.join(allowed_types)}"
                        }
                    )
            # Push channels are device registrations handled by the dedicated
            # upsert path on the collection POST (``type=push``). Block
            # create/PATCH through the generic channel serializer so the
            # queryable platform / lookup_hash can't be desynced from the
            # encrypted token. (Create is also gated by the type allowlist; this
            # additionally covers the read-only-type PATCH path on an existing
            # push channel.) One exception: renaming — ``name`` is display-only
            # metadata with no sync invariant, and re-registration can't carry
            # a rename for a *remote* device.
            instance_type = getattr(self.instance, "type", None)
            if enums.ChannelTypes.PUSH in (channel_type, instance_type):
                is_rename_only = self.instance is not None and set(attrs) <= {"name"}
                if not is_rename_only:
                    raise serializers.ValidationError(
                        {
                            "type": "push channels are managed via device "
                            "registration (POST to this collection with "
                            "type=push); only `name` may be updated."
                        }
                    )
            self._reject_caller_supplied_encrypted_keys(attrs)
            self._validate_api_key_scopes(attrs)
            self._validate_webhook_settings(attrs)
            return attrs

        mailbox = attrs.get("mailbox")
        maildomain = attrs.get("maildomain")

        # Validate that either mailbox or maildomain is set, but not both
        if not mailbox and not maildomain:
            raise serializers.ValidationError(
                "Either mailbox or maildomain must be specified."
            )

        if mailbox and maildomain:
            raise serializers.ValidationError(
                "Cannot specify both mailbox and maildomain."
            )

        self._reject_caller_supplied_encrypted_keys(attrs)
        self._validate_api_key_scopes(attrs)
        self._validate_webhook_settings(attrs)
        return attrs


class ChannelCreateResponseSerializer(ChannelSerializer):
    """Schema-only view of the channel-create 201 response.

    ``ChannelViewSet.create`` returns the full ``ChannelSerializer``
    payload plus the freshly-minted plaintext credentials — surfaced
    exactly once on creation and never retrievable again. They are
    declared here as read-only fields so generated API clients see them
    in the OpenAPI schema. This serializer is never used to serialize a
    response directly; the view assembles the body by hand.
    """

    # ``required=False`` without ``read_only`` so drf-spectacular lists
    # these as optional (not ``required``) in the response schema — each
    # create returns only the one credential matching the channel type /
    # auth_method, so the others are intentionally absent.
    api_key = serializers.CharField(
        required=False,
        help_text=(
            "Plaintext API key — api_key channels and webhook channels "
            "with auth_method=api_key."
        ),
    )
    secret = serializers.CharField(
        required=False,
        help_text="webhook channels with auth_method=jwt — the HMAC/JWT signing secret.",
    )

    class Meta(ChannelSerializer.Meta):
        fields = ChannelSerializer.Meta.fields + [
            "api_key",
            "secret",
        ]


class MessageTemplateSerializer(serializers.ModelSerializer):
    """Serialize message templates for POST/PUT/PATCH operations."""

    type = IntegerChoicesField(choices_class=enums.MessageTemplateTypeChoices)

    is_forced = serializers.BooleanField(
        required=False, default=False, help_text="Set as forced template"
    )
    is_default = serializers.BooleanField(
        required=False,
        default=False,
        help_text="Set as default template (auto-loaded when composing a new message)",
    )
    html_body = serializers.CharField(required=False)
    text_body = serializers.CharField(required=False)
    raw_body = serializers.CharField(required=False)
    metadata = ObjectJSONField(required=False, default=dict)
    signature_id = serializers.UUIDField(required=False, allow_null=True)

    class Meta:
        model = models.MessageTemplate
        fields = [
            "id",
            "name",
            "html_body",
            "text_body",
            "raw_body",
            "type",
            "is_active",
            "is_forced",
            "is_default",
            "metadata",
            "signature_id",
            "created_at",
            "updated_at",
        ]
        read_only_fields = ["id", "created_at", "updated_at"]

    def _build_content_bytes(self, attrs):
        """Build the JSON blob content bytes from body fields."""
        raw_body = attrs.get("raw_body", "")
        try:
            raw_body = json.loads(raw_body) if raw_body else None
        except json.JSONDecodeError as err:
            raise serializers.ValidationError(
                {"raw_body": f"Invalid JSON: {err.msg}"}
            ) from err

        return json.dumps(
            {
                "html": attrs.get("html_body", ""),
                "text": attrs.get("text_body", ""),
                "raw": raw_body,
            },
            separators=(",", ":"),
        ).encode("utf-8")

    def _body_unchanged(self, attrs):
        """Check if body content is identical to the existing blob."""
        instance = self.instance
        if not instance or not instance.blob:
            return False
        content_bytes = self._build_content_bytes(attrs)
        new_hash = hashlib.sha256(content_bytes).digest()
        return new_hash == bytes(instance.blob.sha256)

    def validate(self, attrs):
        """Validate template data."""
        # For creation or update, all content fields must be provided
        # if one of fields html_body, text_body, raw_body is provided, all must be provided
        if any(field in attrs for field in ["html_body", "text_body", "raw_body"]):
            if not all(
                field in attrs for field in ["html_body", "text_body", "raw_body"]
            ):
                raise serializers.ValidationError(
                    "All content fields (html_body, text_body, raw_body) must be provided together."
                )

        if "html_body" in attrs:
            # Skip expensive image validation if body content hasn't changed
            if self._body_unchanged(attrs):
                attrs.pop("html_body")
                attrs.pop("text_body")
                attrs.pop("raw_body")
            else:
                _html, images = extract_inline_images_html(attrs["html_body"])
                total_image_size = 0
                for image in images:
                    total_image_size += image["size"]
                    if image["size"] > settings.MAX_TEMPLATE_IMAGE_SIZE:
                        max_mb = settings.MAX_TEMPLATE_IMAGE_SIZE / (1024 * 1024)
                        image_mb = image["size"] / (1024 * 1024)
                        raise serializers.ValidationError(
                            {
                                "html_body": (
                                    'Image "%(name)s" (%(size)s MB) exceeds'
                                    " the %(max)s MB limit."
                                )
                                % {
                                    "name": image["name"],
                                    "size": f"{image_mb:.1f}",
                                    "max": f"{max_mb:.0f}",
                                }
                            }
                        )
                    if total_image_size > settings.MAX_OUTGOING_ATTACHMENT_SIZE:
                        max_mb = settings.MAX_OUTGOING_ATTACHMENT_SIZE / (1024 * 1024)
                        total_mb = total_image_size / (1024 * 1024)
                        raise serializers.ValidationError(
                            {
                                "html_body": (
                                    "Total attachment size (%(total_size)s MB) exceeds the %(max_size)s MB limit. "
                                    "Please remove or reduce attachments."
                                )
                                % {
                                    "total_size": f"{total_mb:.1f}",
                                    "max_size": f"{max_mb:.0f}",
                                }
                            }
                        )

        # Autoreply-specific validation
        template_type = attrs.get("type") or (
            self.instance.type if self.instance else None
        )
        if template_type == enums.MessageTemplateTypeChoices.AUTOREPLY:
            if attrs.get("is_forced"):
                raise serializers.ValidationError(
                    {"is_forced": "Autoreply templates cannot be forced."}
                )
            if attrs.get("is_default"):
                raise serializers.ValidationError(
                    {"is_default": "Autoreply templates cannot be default."}
                )
            metadata = attrs.get("metadata")
            # Skip metadata validation on update
            if not self.instance or metadata is not None:
                try:
                    models.MessageTemplate(
                        type=template_type,
                        metadata=metadata or {},
                    ).validate_autoreply_metadata()
                except DjangoValidationError as exc:
                    raise serializers.ValidationError(exc.message_dict) from exc

        # Validate and resolve signature_id
        signature_id = attrs.pop("signature_id", None)
        if signature_id:
            mailbox = self.context.get("mailbox") or (
                self.instance.mailbox if self.instance else None
            )
            domain = self.context.get("domain") or (
                self.instance.maildomain if self.instance else None
            )

            # Build scope filter: signature must belong to the same mailbox or domain
            scope_filter = Q()
            if mailbox:
                scope_filter |= Q(mailbox=mailbox) | Q(maildomain=mailbox.domain)
            if domain:
                scope_filter |= Q(maildomain=domain)

            if not scope_filter:
                raise serializers.ValidationError(
                    {"signature_id": "Invalid or inaccessible signature."}
                )

            signature = models.MessageTemplate.objects.filter(
                scope_filter,
                id=signature_id,
                type=enums.MessageTemplateTypeChoices.SIGNATURE,
                is_active=True,
            ).first()

            if not signature:
                raise serializers.ValidationError(
                    {"signature_id": "Invalid or inaccessible signature."}
                )
            attrs["signature"] = signature
        elif signature_id is None and "signature_id" in self.initial_data:
            attrs["signature"] = None

        return super().validate(attrs)

    def create(self, validated_data):
        """Create template with relationships and ensure atomic content creation."""
        content_bytes = self._build_content_bytes(validated_data)
        validated_data.pop("html_body", None)
        validated_data.pop("text_body", None)
        validated_data.pop("raw_body", None)
        validated_data["maildomain"] = self.context.get("domain")
        validated_data["mailbox"] = self.context.get("mailbox")

        with transaction.atomic():
            blob = models.Blob.objects.create_blob(
                content=content_bytes,
                content_type="application/json",
            )
            validated_data["blob"] = blob
            return super().create(validated_data)

    def update(self, instance, validated_data):
        """Update template with relationships. Not allowed to change mailbox or maildomain."""
        has_body = any(
            field in validated_data for field in ["html_body", "text_body", "raw_body"]
        )

        with transaction.atomic():
            if has_body:
                content_bytes = self._build_content_bytes(validated_data)
                validated_data.pop("html_body", None)
                validated_data.pop("text_body", None)
                validated_data.pop("raw_body", None)

                # Drop the old blob's reference and let the GC sweep
                # collect it if no other row links to it. Direct delete
                # would race with concurrent dedup / offload paths.
                old_blob_id = instance.blob_id
                instance.blob = None
                if old_blob_id:
                    schedule_for_gc(old_blob_id)

                blob = models.Blob.objects.create_blob(
                    content=content_bytes,
                    content_type="application/json",
                )
                validated_data["blob"] = blob

            return super().update(instance, validated_data)


class SendMessageSerializer(serializers.Serializer):
    """Serializer for sending messages."""

    messageId = serializers.UUIDField(required=True)
    senderId = serializers.UUIDField(required=True)
    archive = serializers.BooleanField(required=False, default=False)
    textBody = serializers.CharField(required=False, allow_blank=True)
    htmlBody = serializers.CharField(required=False, allow_blank=True)

    class Meta:
        fields = ["messageId", "senderId", "archive", "textBody", "htmlBody"]

    def create(self, validated_data):
        """This serializer is only used to validate the data, not to create or update."""

    def update(self, instance, validated_data):
        """This serializer is only used to validate the data, not to create or update."""


class PartialDriveItemSerializer(serializers.Serializer):
    """
    Serializer for Drive Item resource (OpenAPI purpose only...).
    It supports partially the Drive Item resource response structure.
    We declare only fields that are useful in the Messages context.
    """

    id = serializers.UUIDField(required=True)
    filename = serializers.CharField(required=True)
    mimetype = serializers.CharField(required=True)
    size = serializers.IntegerField(required=True)

    class Meta:
        fields = ["id", "filename", "mimetype", "size"]
        read_only_fields = fields

    def create(self, validated_data):
        """This serializer is only used to validate the data, not to create or update."""

    def update(self, instance, validated_data):
        """This serializer is only used to validate the data, not to create or update."""


class DomainsField(serializers.Field):
    """Accepts either a JSON list of strings or a comma-separated string."""

    def to_internal_value(self, data):
        if isinstance(data, str):
            data = [d.strip() for d in data.split(",") if d.strip()]
        if not isinstance(data, list):
            raise serializers.ValidationError(
                "Expected a list of domains or a comma-separated string."
            )
        if not data:
            raise serializers.ValidationError("At least one domain is required.")
        return data

    def to_representation(self, value):
        return value


class ProvisioningMailDomainSerializer(serializers.Serializer):
    """Serializer for the provisioning endpoint that creates mail domains."""

    domains = DomainsField()
    custom_attributes = serializers.JSONField(required=False, default=dict)
    oidc_autojoin = serializers.BooleanField(required=False, default=True)
    identity_sync = serializers.BooleanField(required=False, default=False)

    def create(self, validated_data):
        """This serializer is only used to validate the data, not to create or update."""

    def update(self, instance, validated_data):
        """This serializer is only used to validate the data, not to create or update."""


class ThreadBulkDeleteRequestSerializer(serializers.Serializer):
    """Payload for the bulk-delete endpoint: a scope and the threads/messages
    to permanently delete."""

    # Scopes accepted by the bulk-delete endpoint, mapping each to the queryset
    # filter selecting the messages whose rows get permanently removed. Only
    # "draft" is exposed for now: trashed deletion is intentionally not offered
    # until the product behavior for trashed messages is decided.
    BULK_DELETE_SCOPE_FILTERS = {"draft": {"is_draft": True}}
    BULK_DELETE_SCOPES = list(BULK_DELETE_SCOPE_FILTERS)

    scope = serializers.ChoiceField(
        choices=BULK_DELETE_SCOPES,
        help_text=(
            "Which messages to permanently delete. Only 'draft' "
            "(draft messages) is supported."
        ),
    )
    thread_ids = serializers.ListField(
        child=serializers.UUIDField(),
        required=False,
        allow_empty=True,
        default=list,
        help_text="Threads whose scope-matching messages should be deleted.",
    )
    message_ids = serializers.ListField(
        child=serializers.UUIDField(),
        required=False,
        allow_empty=True,
        default=list,
        help_text="Specific messages to delete (still scope-filtered).",
    )

    def validate(self, attrs):
        """Require at least one target list to be non-empty."""
        if not attrs["thread_ids"] and not attrs["message_ids"]:
            raise serializers.ValidationError(
                "Provide at least one of thread_ids or message_ids."
            )
        return attrs

    def create(self, validated_data):
        """This serializer is only used to validate the data, not to create or update."""

    def update(self, instance, validated_data):
        """This serializer is only used to validate the data, not to create or update."""


class WebPushKeysSerializer(serializers.Serializer):
    """The Web Push subscription key pair (``p256dh`` and ``auth``)."""

    # Both are typed as plain (non-blank) strings on purpose. That is enough to
    # reject the poison shape a bare DictField allowed — key *values* that
    # aren't strings (``{p256dh: {...}, auth: [...]}``), which used to be stored
    # and then fail deterministically (retrying forever) at send time. We do NOT
    # validate the base64url encoding or byte lengths: browsers are the only
    # web-push clients and always emit well-formed keys, so length checks would
    # be a footgun (rejecting a future non-standard-but-valid client) for no
    # real gain — a genuinely undeliverable key is now handled gracefully at
    # send time (marked stale, never retried; see ``services/push/webpush.py``).
    # Declaring exactly these two fields also means only they get stored.
    p256dh = serializers.CharField()
    auth = serializers.CharField()

    def create(self, validated_data):
        """Input-only nested serializer; never persisted directly."""

    def update(self, instance, validated_data):
        """Input-only nested serializer; never persisted directly."""


class PushDeviceRegistrationSerializer(serializers.Serializer):
    """Validate a mobile/web device push registration.

    Input for the push branch of ``UserChannelViewSet.create`` (a POST with
    ``type=push``): the platform, the opaque push token, and optional client
    metadata. The viewset turns this into a user-scoped ``push`` Channel (token
    stored encrypted). ``keys`` carries the Web Push p256dh/auth pair; unused
    for native platforms.
    """

    platform = serializers.ChoiceField(choices=enums.PushPlatformChoices.choices)
    token = serializers.CharField(max_length=8192, trim_whitespace=False)
    app_version = serializers.CharField(max_length=64, required=False, allow_blank=True)
    keys = WebPushKeysSerializer(required=False)
    name = serializers.CharField(max_length=255, required=False, allow_blank=True)

    def validate_token(self, value):
        """Validate the token is not an empty value."""
        if not value.strip():
            raise serializers.ValidationError("token must not be empty")
        return value

    def validate(self, attrs):
        """Web Push needs the subscription keys, or the device can never be
        delivered to — reject at registration rather than silently accepting a
        web device that gets no pushes. Non-web platforms don't use keys.

        Only the presence of the pair is enforced here; the nested
        ``WebPushKeysSerializer`` just types the two values as strings.
        """
        if attrs.get("platform") == enums.PushPlatformChoices.WEB:
            if not attrs.get("keys"):
                raise serializers.ValidationError(
                    {"keys": "web push requires keys.p256dh and keys.auth."}
                )
        else:
            attrs.pop("keys", None)
        return attrs

    def create(self, validated_data):
        """Input-only serializer; the viewset performs the registration."""

    def update(self, instance, validated_data):
        """Input-only serializer; the viewset performs the registration."""


class PushChannelCreateSerializer(PushDeviceRegistrationSerializer):
    """Schema variant of the push registration body for ``POST .../channels/``.

    Identical to ``PushDeviceRegistrationSerializer`` plus the ``type``
    discriminator, so the polymorphic create endpoint documents the push shape
    ({type:"push", platform, token, keys?, name?, app_version?}) alongside the
    generic channel shape. Validation at runtime still uses the parent.
    """

    type = serializers.ChoiceField(choices=[enums.ChannelTypes.PUSH])
