"""API ViewSet for Thread model."""
# pylint: disable=too-many-lines

from django.conf import settings
from django.db import transaction
from django.db.models import Count, Exists, OuterRef, Prefetch, Q
from django.db.models.functions import Coalesce

import rest_framework as drf
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
    OpenApiParameter,
    OpenApiResponse,
    extend_schema,
    inline_serializer,
)
from rest_framework import mixins, status, viewsets
from rest_framework import serializers as drf_serializers
from rest_framework.generics import get_object_or_404
from rest_framework.response import Response

from core import enums, models
from core.ai.thread_summarizer import summarize_thread
from core.mda.utils import thread_snippet
from core.services.search import search_threads

from .. import permissions, serializers


class ThreadViewSet(
    viewsets.GenericViewSet,
    mixins.ListModelMixin,
    mixins.RetrieveModelMixin,
    mixins.DestroyModelMixin,
):
    """ViewSet for Thread model."""

    serializer_class = serializers.ThreadSerializer
    permission_classes = [permissions.IsAuthenticated]
    lookup_field = "pk"
    lookup_url_kwarg = "pk"

    def get_permissions(self):
        """Use HasThreadEditAccess for actions that require EDITOR role.

        `refresh_summary` mutates thread state (writes `thread.summary`)
        so it must also gate on full edit rights, not just authentication.
        """
        if self.action in ("destroy", "split", "refresh_summary", "bulk_delete"):
            return [permissions.HasThreadEditAccess()]
        return super().get_permissions()

    def get_serializer_context(self):
        """Add mailbox_id to serializer context for scoped serialization."""
        context = super().get_serializer_context()
        context["mailbox_id"] = self.request.GET.get("mailbox_id")
        return context

    def retrieve(self, request, *args, **kwargs):
        """Retrieve a single thread, ignoring spam/trashed filters.

        Deep-link sharing relies on a stable URL that survives state
        changes: a shared link must keep working after the thread is
        archived, marked as spam, or moved to trash. The ThreadAccess
        permission check still applies, so unauthorized users get 404.
        """
        queryset = self.filter_queryset(
            self.get_queryset(exclude_spam=False, exclude_trashed=False)
        )
        instance = get_object_or_404(queryset, pk=kwargs["pk"])
        self.check_object_permissions(request, instance)
        serializer = self.get_serializer(instance)
        return Response(serializer.data)

    def get_queryset(self, exclude_spam: bool = True, exclude_trashed: bool = True):
        """Restrict results to threads accessible by the current user."""
        user = self.request.user
        mailbox_id = self.request.GET.get("mailbox_id")
        label_slug = self.request.GET.get("label_slug")

        # Base queryset: Threads the user has access to via ThreadAccess
        queryset = models.Thread.objects.filter(
            Exists(
                models.ThreadAccess.objects.filter(
                    mailbox__accesses__user=user, thread=OuterRef("pk")
                )
            )
        ).distinct()

        if mailbox_id:
            # Ensure the user actually has access to the specified mailbox_id itself
            try:
                mailbox = models.Mailbox.objects.get(id=mailbox_id, accesses__user=user)
                # Use the mailbox.threads_viewer property to get threads
                queryset = mailbox.threads_viewer
            except models.Mailbox.DoesNotExist as e:
                raise drf.exceptions.PermissionDenied(
                    "You do not have access to this mailbox."
                ) from e

        queryset = self._annotate_thread_permissions(queryset, user, mailbox_id)

        if label_slug:
            # Filter threads by label slug, ensuring user has access to the label's mailbox
            # Get labels accessible to the user, joining with mailbox access
            labels = models.Label.objects.filter(
                slug=label_slug,
                mailbox__accesses__user=user,
            )
            if mailbox_id:
                # Further filter by mailbox if specified
                labels = labels.filter(mailbox__id=mailbox_id)

            # Filter threads that have any of these labels
            queryset = queryset.filter(labels__in=labels)

        # Apply boolean filters
        # These filters operate on the Thread model's boolean fields
        filter_mapping = {
            "has_unread": "_has_unread",
            "has_trashed": "has_trashed",
            "has_archived": "has_archived",
            "has_draft": "has_draft",
            "has_starred": "_has_starred",
            "has_sender": "has_sender",
            "has_active": "has_active",
            "has_messages": "has_messages",
            "has_attachments": "has_attachments",
            "has_delivery_pending": "has_delivery_pending",
            "has_unread_mention": "_has_unread_mention",
            "has_mention": "_has_mention",
            "has_assigned_to_me": "_has_assigned_to_me",
            "has_unassigned": "_has_unassigned",
            "is_trashed": "is_trashed",
            "is_spam": "is_spam",
        }

        query_params = self.request.GET
        for param, filter_field in filter_mapping.items():
            # Exclude fully trashed threads by default
            if exclude_trashed and param == "is_trashed":
                value = query_params.get(
                    param, None if query_params.get("has_trashed") == "1" else "0"
                )
            # Exclude spam by default except if we are looking for trashed threads
            elif exclude_spam and param == "is_spam":
                value = query_params.get(
                    param, None if query_params.get("has_trashed") == "1" else "0"
                )
            else:
                value = query_params.get(param)

            if value is not None and filter_field is not None:
                if value == "1":
                    queryset = queryset.filter(**{filter_field: True})
                else:
                    queryset = queryset.filter(**{filter_field: False})

        order_expression = self._get_order_expression(query_params)
        queryset = queryset.order_by(order_expression, "-created_at")
        return queryset

    @staticmethod
    def _annotate_thread_permissions(queryset, user, mailbox_id):
        """Attach permission/state annotations and prefetches expected by ThreadSerializer.

        Shared between the regular DB queryset and the OpenSearch fallback so
        both code paths always expose the same fields (e.g. ``events_count``,
        ``assigned_users``), avoiding silent divergence in the serialized
        payload.
        """
        can_edit_qs = models.ThreadAccess.objects.filter(
            thread=OuterRef("pk"),
            role=enums.ThreadAccessRoleChoices.EDITOR,
            mailbox__accesses__user=user,
            mailbox__accesses__role__in=enums.MAILBOX_ROLES_CAN_EDIT,
        )
        if mailbox_id:
            can_edit_qs = can_edit_qs.filter(mailbox_id=mailbox_id)

        return queryset.annotate(
            _has_unread=models.ThreadAccess.thread_unread_filter(user, mailbox_id),
            _has_starred=models.ThreadAccess.thread_starred_filter(user, mailbox_id),
            _has_unread_mention=Exists(
                models.UserEvent.objects.filter(
                    thread=OuterRef("pk"),
                    user=user,
                    type=enums.UserEventTypeChoices.MENTION,
                    read_at__isnull=True,
                )
            ),
            _has_mention=Exists(
                models.UserEvent.objects.filter(
                    thread=OuterRef("pk"),
                    user=user,
                    type=enums.UserEventTypeChoices.MENTION,
                )
            ),
            _has_assigned_to_me=Exists(
                models.UserEvent.objects.filter(
                    thread=OuterRef("pk"),
                    user=user,
                    type=enums.UserEventTypeChoices.ASSIGN,
                )
            ),
            _has_unassigned=~Exists(
                models.UserEvent.objects.filter(
                    thread=OuterRef("pk"),
                    type=enums.UserEventTypeChoices.ASSIGN,
                )
            ),
            events_count=Count("events", distinct=True),
            _can_edit=Exists(can_edit_qs),
        ).prefetch_related(
            # Feeds ThreadSerializer.get_assigned_users without N+1. UserEvent
            # rows with type=ASSIGN are the source of truth for "currently
            # assigned" (created on assign, deleted on unassign).
            Prefetch(
                "user_events",
                queryset=models.UserEvent.objects.filter(
                    type=enums.UserEventTypeChoices.ASSIGN
                )
                .select_related("user")
                .order_by("created_at"),
                to_attr="_assigned_user_events",
            ),
            # Feeds ThreadSerializer.get_accesses. ``mailbox__domain`` is
            # needed because ``Mailbox.__str__`` (used by MailboxLightSerializer)
            # reads ``self.domain.name`` — without it we'd get one query per
            # thread to resolve the mail domain.
            Prefetch(
                "accesses",
                queryset=models.ThreadAccess.objects.select_related(
                    "mailbox", "mailbox__domain", "mailbox__contact"
                ),
                to_attr="_accesses_with_mailbox",
            ),
            # Feeds ThreadSerializer.get_messages. The serializer only emits
            # message IDs in chronological order, so ordering is baked into
            # the prefetch to avoid a re-query per thread.
            Prefetch(
                "messages",
                queryset=models.Message.objects.only(
                    "id", "thread_id", "created_at"
                ).order_by("created_at"),
                to_attr="_ordered_messages",
            ),
            # Feeds ThreadSerializer.get_labels. Labels are scoped to the
            # mailbox context when provided; when it is absent the serializer
            # short-circuits to ``[]`` and no prefetch is needed.
            *(
                [
                    Prefetch(
                        "labels",
                        queryset=models.Label.objects.filter(mailbox_id=mailbox_id),
                        to_attr="_scoped_labels",
                    )
                ]
                if mailbox_id
                else []
            ),
        )

    @staticmethod
    def _get_order_expression(query_params):
        """Return the ordering expression based on the active view filter."""
        view_field_map = {
            "has_trashed": "trashed_messaged_at",
            "has_draft": "draft_messaged_at",
            "has_sender": "sender_messaged_at",
            "has_archived": "archived_messaged_at",
            "has_active": "active_messaged_at",
            "has_starred": "active_messaged_at",
        }
        for param, field in view_field_map.items():
            if query_params.get(param) == "1":
                return f"-{field}"

        # Draft-only threads have messaged_at=NULL, fall back to draft_messaged_at
        return Coalesce("messaged_at", "draft_messaged_at").desc()

    def destroy(self, request, *args, **kwargs):
        """Delete a thread, requiring EDITOR role on the thread."""
        thread = self.get_object()
        thread.delete()
        return drf.response.Response(status=status.HTTP_204_NO_CONTENT)

    @extend_schema(
        tags=["threads"],
        parameters=[
            OpenApiParameter(
                name="mailbox_id",
                type=OpenApiTypes.UUID,
                location=OpenApiParameter.QUERY,
                description="Filter threads by mailbox ID.",
            ),
            OpenApiParameter(
                name="label_slug",
                type=OpenApiTypes.STR,
                location=OpenApiParameter.QUERY,
                description="Filter threads by label slug.",
            ),
            OpenApiParameter(
                name="search",
                type=OpenApiTypes.STR,
                location=OpenApiParameter.QUERY,
                description="Search threads by content (subject, sender, recipients, message body).",
            ),
            OpenApiParameter(
                name="has_trashed",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads that are trashed (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_archived",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads that are archived (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_draft",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads with draft messages (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_starred",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads with starred messages (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_attachments",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads with attachments (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_sender",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads with messages sent by the user (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_delivery_pending",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description=(
                    "Filter threads with delivery pending messages: sending, retry or failed (1=true, 0=false)."
                ),
            ),
            OpenApiParameter(
                name="has_unread_mention",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads with unread mentions for the current user (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_mention",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads with any mention (read or unread) for the current user (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_assigned_to_me",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads assigned to the current user (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_unassigned",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads with no active assignment from any user (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="stats_fields",
                type=OpenApiTypes.STR,
                location=OpenApiParameter.QUERY,
                required=True,
                description="""Comma-separated list of fields to aggregate.
                Special values: 'all' (count all threads), 'all_unread' (count all unread threads).
                Boolean fields: has_trashed, has_draft, has_starred, has_attachments, has_archived,
                has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages, has_unread_mention, has_mention, has_assigned_to_me, has_unassigned.
                Unread variants ('_unread' suffix): count threads where the condition is true AND the thread is unread.
                Examples: 'all,all_unread', 'has_starred,has_starred_unread', 'is_spam,is_spam_unread'""",
                enum=list(enums.THREAD_STATS_FIELDS_MAP.keys()),
                style="form",
                explode=False,
            ),
        ],
        responses={
            200: OpenApiResponse(
                response={
                    "type": "object",
                    "additionalProperties": {"type": "integer"},
                },
                description=(
                    "A dictionary containing the aggregated counts. "
                    "Keys correspond to the fields requested via the `stats_fields` query parameter. "
                    "Each value is an integer count. Keys not requested will not be present in the response."
                ),
            ),
            400: OpenApiResponse(
                response={
                    "type": "object",
                    "properties": {"detail": {"type": "string"}},
                },
                description=(
                    "Returned if `stats_fields` parameter is missing or contains invalid fields."
                ),
            ),
        },
        description="Get aggregated statistics for threads based on filters.",
    )
    @drf.decorators.action(
        detail=False,
        methods=["get"],
        url_path="stats",
        url_name="stats",
        permission_classes=[permissions.IsAuthenticated],
    )
    def stats(self, request):
        """Retrieve aggregated statistics for threads accessible by the user."""
        queryset = self.get_queryset(exclude_spam=False, exclude_trashed=False)
        stats_fields_param = request.query_params.get("stats_fields", "")

        if not stats_fields_param:
            return drf.response.Response(
                {"detail": "Missing 'stats_fields' query parameter."},
                status=drf.status.HTTP_400_BAD_REQUEST,
            )

        requested_fields = [field.strip() for field in stats_fields_param.split(",")]

        # Define valid base fields that can be counted
        valid_base_fields = {
            "has_trashed",
            "has_archived",
            "has_draft",
            "has_starred",
            "has_attachments",
            "has_sender",
            "has_active",
            "has_delivery_failed",
            "has_delivery_pending",
            "has_unread_mention",
            "has_mention",
            "has_assigned_to_me",
            "has_unassigned",
            "is_spam",
            "has_messages",
        }

        # Special fields
        special_fields = {"all", "all_unread"}

        # Base fields that cannot be combined with the "_unread" suffix because
        # they are annotations (not real model columns) and their unread variant
        # is either already exposed (has_unread_mention) or meaningless.
        annotation_fields = {
            "has_mention",
            "has_unread_mention",
            "has_assigned_to_me",
            "has_unassigned",
        }

        # Validate requested fields
        for field in requested_fields:
            if field in special_fields:
                continue
            if field.endswith("_unread"):
                # Extract base field name and validate
                base_field = field[:-7]  # Remove "_unread" suffix
                if base_field not in valid_base_fields:
                    return drf.response.Response(
                        {"detail": f"Invalid base field in '{field}': {base_field}"},
                        status=drf.status.HTTP_400_BAD_REQUEST,
                    )
                if base_field in annotation_fields:
                    return drf.response.Response(
                        {"detail": f"Invalid field requested in stats_fields: {field}"},
                        status=drf.status.HTTP_400_BAD_REQUEST,
                    )
            elif field in valid_base_fields:
                continue
            else:
                return drf.response.Response(
                    {"detail": f"Invalid field requested in stats_fields: {field}"},
                    status=drf.status.HTTP_400_BAD_REQUEST,
                )

        # Build conditions from annotations (always available)
        unread_condition = Q(_has_unread=True)
        starred_condition = Q(_has_starred=True)
        unread_mention_condition = Q(_has_unread_mention=True)
        mention_condition = Q(_has_mention=True)
        assigned_to_me_condition = Q(_has_assigned_to_me=True)
        unassigned_condition = Q(_has_unassigned=True)

        aggregations = {}
        for field in requested_fields:
            agg_key = f"count_{field}"

            if field == "all":
                aggregations[agg_key] = Count("pk")
            elif field == "all_unread":
                aggregations[agg_key] = Count("pk", filter=unread_condition)
            elif field == "has_starred":
                aggregations[agg_key] = Count("pk", filter=starred_condition)
            elif field == "has_starred_unread":
                aggregations[agg_key] = Count(
                    "pk", filter=starred_condition & unread_condition
                )
            elif field == "has_unread_mention":
                aggregations[agg_key] = Count("pk", filter=unread_mention_condition)
            elif field == "has_mention":
                aggregations[agg_key] = Count("pk", filter=mention_condition)
            elif field == "has_assigned_to_me":
                aggregations[agg_key] = Count("pk", filter=assigned_to_me_condition)
            elif field == "has_unassigned":
                aggregations[agg_key] = Count("pk", filter=unassigned_condition)
            elif field.endswith("_unread"):
                base_field = field[:-7]
                base_condition = Q(**{base_field: True})
                aggregations[agg_key] = Count(
                    "pk", filter=base_condition & unread_condition
                )
            else:
                aggregations[agg_key] = Count("pk", filter=Q(**{field: True}))

        if not aggregations:
            return drf.response.Response(
                {"detail": "No valid fields provided in stats_fields."},
                status=drf.status.HTTP_400_BAD_REQUEST,
            )

        aggregated_data = queryset.aggregate(**aggregations)

        # Map back to the original field names and replace None with 0
        result = {}
        for field in requested_fields:
            agg_key = f"count_{field}"
            value = aggregated_data.get(agg_key, 0)
            result[field] = value if value is not None else 0

        return drf.response.Response(result)

    @extend_schema(
        tags=["threads"],
        parameters=[
            OpenApiParameter(
                name="mailbox_id",
                type=OpenApiTypes.UUID,
                location=OpenApiParameter.QUERY,
                description="Filter threads by mailbox ID.",
            ),
            OpenApiParameter(
                name="label_slug",
                type=OpenApiTypes.STR,
                location=OpenApiParameter.QUERY,
                description="Filter threads by label slug.",
            ),
            OpenApiParameter(
                name="search",
                type=OpenApiTypes.STR,
                location=OpenApiParameter.QUERY,
                description="Search threads by content (subject, sender, recipients, message body).",
            ),
            OpenApiParameter(
                name="has_trashed",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads that have trashed messages (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="is_trashed",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads that have all messages trashed (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_draft",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads with draft messages (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_starred",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads with starred messages (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_attachments",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads with attachments (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_sender",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads with messages sent by the user (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_active",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads that have active messages (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_messages",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads that have messages (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_archived",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads that have archived (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="is_spam",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads that are spam (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_delivery_pending",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description=(
                    "Filter threads that have delivery pending messages: sending, retry or failed (1=true, 0=false)."
                ),
            ),
            OpenApiParameter(
                name="has_unread",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads with unread messages (1=true, 0=false). Requires mailbox_id.",
            ),
            OpenApiParameter(
                name="has_unread_mention",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads with unread mentions for the current user (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_mention",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads with any mention (read or unread) for the current user (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_assigned_to_me",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads assigned to the current user (1=true, 0=false).",
            ),
            OpenApiParameter(
                name="has_unassigned",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.QUERY,
                description="Filter threads with no active assignment from any user (1=true, 0=false).",
            ),
        ],
    )
    def list(self, request, *args, **kwargs):
        """List threads with optional search functionality."""
        search_query = request.query_params.get("search", "").strip()
        mailbox_id = request.query_params.get("mailbox_id")

        if mailbox_id:
            mailbox_access = models.MailboxAccess.objects.filter(
                mailbox=mailbox_id, user=request.user
            ).first()
            if mailbox_access:
                mailbox_access.mark_accessed()
            else:
                raise drf.exceptions.PermissionDenied(
                    "You do not have access to this mailbox."
                )

        # If search is provided and OpenSearch is available, use it
        if search_query and len(settings.OPENSEARCH_HOSTS[0]) > 0:
            # Get the mailbox_id for filtering

            # Build filters from query parameters
            # TODO: refactor as thread filters are not the same as message filters (has_messages, has_active)
            es_filters = {}
            for param, value in request.query_params.items():
                if param.startswith("has_") and value in {"0", "1"}:
                    # Remove 'has_' prefix
                    es_filters[f"is_{param[4:]}"] = value == "1"
                elif param.startswith("is_") and value in {"0", "1"}:
                    es_filters[param] = value == "1"

            # Get page parameters
            page = int(self.paginator.get_page_number(request, self))
            page_size = int(self.paginator.get_page_size(request))

            # Scope the search to the user's mailboxes. With an explicit
            # mailbox_id we already verified access above; without one, fall
            # back to every mailbox the user can access — never the whole
            # cluster. An unscoped query leaks hit-totals and content-existence
            # across mailboxes even though the bodies are access-filtered below.
            if mailbox_id:
                search_mailbox_ids = [mailbox_id]
            else:
                search_mailbox_ids = [
                    str(mid)
                    for mid in models.MailboxAccess.objects.filter(
                        user=request.user
                    ).values_list("mailbox_id", flat=True)
                ]

            # Get search results from OpenSearch
            results = search_threads(
                query=search_query,
                mailbox_ids=search_mailbox_ids,
                filters=es_filters,
                from_offset=(page - 1) * page_size,
                size=page_size,
            )

            # Retrieve and order threads from database
            ordered_threads = []
            if len(results["threads"]) > 0:
                # Get the thread IDs from the search results
                thread_ids = [thread["id"] for thread in results["threads"]]

                # Filter by access control: only return threads the user
                # can access via ThreadAccess. We don't use get_queryset()
                # because it applies extra filters (trashed, spam, labels,
                # booleans) that OpenSearch already handles.
                threads = models.Thread.objects.filter(
                    id__in=thread_ids,
                ).filter(
                    Exists(
                        models.ThreadAccess.objects.filter(
                            mailbox__accesses__user=request.user,
                            thread=OuterRef("pk"),
                        )
                    ),
                )
                threads = self._annotate_thread_permissions(
                    threads, request.user, mailbox_id
                )

                # Order the threads in the same order as the search results
                thread_dict = {str(thread.id): thread for thread in threads}
                ordered_threads = [
                    thread_dict[thread_id]
                    for thread_id in thread_ids
                    if thread_id in thread_dict
                ]

            # Return a response with minimal pagination info
            # (only page numbers for next/previous, not full URLs)
            # OpenSearch has already handled pagination, so we can't use the default DRF paginator.
            # The frontend only needs: total count, and non-null next/previous values
            # to determine if there are more pages available.
            serializer = self.get_serializer(ordered_threads, many=True)
            total_count = results.get("total", 0)
            return drf.response.Response(
                {
                    "count": total_count,
                    "next": page + 1 if (page * page_size) < total_count else None,
                    "previous": page - 1 if page > 1 else None,
                    "results": serializer.data,
                }
            )
        # Fall back to regular DB query if no search query or OpenSearch not available

        return super().list(request, *args, **kwargs)

    @extend_schema(
        responses={
            200: OpenApiResponse(
                response={
                    "type": "object",
                    "properties": {
                        "summary": {"type": "string"},
                    },
                },
                description="Thread summary retrieved successfully.",
            ),
            403: OpenApiResponse(
                response={"detail": "Permission denied"},
                description="User does not have permission to access this thread.",
            ),
        },
        tags=["threads"],
    )
    @drf.decorators.action(detail=True, methods=["get"], url_path="summary")
    def get_summary(self, request, pk):  # pylint: disable=unused-argument
        """Retrieve the summary of a thread."""
        thread = self.get_object()
        return drf.response.Response({"summary": thread.summary})

    @extend_schema(
        responses={
            200: OpenApiResponse(
                response={
                    "type": "object",
                    "properties": {"summary": {"type": "string"}},
                },
                description="Summary successfully refreshed.",
            ),
            403: OpenApiResponse(
                response={"detail": "Permission denied"},
                description="User does not have permission to refresh the summary of this thread.",
            ),
        },
        tags=["threads"],
    )
    @drf.decorators.action(
        detail=True,
        methods=["post"],
        url_path="refresh-summary",
        url_name="refresh-summary",
    )
    def refresh_summary(self, request, pk):  # pylint: disable=unused-argument
        """Refresh the summary of a thread."""
        thread = self.get_object()
        thread.summary = summarize_thread(thread)
        thread.save()
        return drf.response.Response(
            {"summary": thread.summary}, status=status.HTTP_200_OK
        )

    @extend_schema(
        tags=["threads"],
        request=inline_serializer(
            name="ThreadSplitRequest",
            fields={
                "message_id": drf_serializers.UUIDField(
                    required=True,
                    help_text="ID of the message to split from. This message and all "
                    "chronologically later messages will be moved to a new thread.",
                ),
            },
        ),
        responses={
            201: serializers.ThreadSerializer,
            400: OpenApiResponse(
                response={
                    "type": "object",
                    "properties": {"detail": {"type": "string"}},
                },
                description="Validation error.",
            ),
            403: OpenApiResponse(
                response={"detail": "Permission denied"},
                description="User does not have editor permission on this thread.",
            ),
        },
        description="Split a thread by moving the specified message and all later "
        "messages to a new thread.",
    )
    @drf.decorators.action(
        detail=True,
        methods=["post"],
        url_path="split",
        url_name="split",
    )
    def split(self, request, pk=None):  # pylint: disable=unused-argument
        """Split a thread at the given message, moving it and all later messages to a new thread."""
        # Kill-switch: when the feature is disabled at the instance level,
        # behave as if the endpoint does not exist so clients cannot probe
        # it via error messages.
        if not settings.FEATURE_THREAD_SPLIT:
            raise drf.exceptions.NotFound()

        old_thread = self.get_object()

        # Validate request
        message_id = request.data.get("message_id")
        if not message_id:
            return drf.response.Response(
                {"detail": "message_id is required."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        try:
            split_message = models.Message.objects.get(id=message_id)
        except models.Message.DoesNotExist:
            return drf.response.Response(
                {"detail": "Message not found."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        if split_message.thread_id != old_thread.id:
            return drf.response.Response(
                {"detail": "Message does not belong to this thread."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        if split_message.is_draft:
            return drf.response.Response(
                {"detail": "Cannot split at a draft message."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        # Get all messages in the thread ordered chronologically
        all_messages = old_thread.messages.order_by("created_at")
        total_count = all_messages.count()

        if total_count <= 1:
            return drf.response.Response(
                {"detail": "Cannot split a thread with only one message."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        first_message = all_messages.first()
        if first_message.id == split_message.id:
            return drf.response.Response(
                {"detail": "Cannot split at the first message in the thread."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        # Determine messages to move (split_message and all later)
        messages_to_move = all_messages.filter(created_at__gte=split_message.created_at)

        with transaction.atomic():
            new_subject = split_message.subject or old_thread.subject
            snippet = thread_snippet(
                split_message.get_parsed_data(),
                fallback=new_subject or "",
            )

            # Create new thread
            new_thread = models.Thread.objects.create(
                subject=new_subject,
                snippet=snippet,
            )

            # Copy ThreadAccess entries
            old_accesses = models.ThreadAccess.objects.filter(thread=old_thread)
            new_accesses = [
                models.ThreadAccess(
                    thread=new_thread,
                    mailbox=access.mailbox,
                    role=access.role,
                    read_at=access.read_at,
                    starred_at=access.starred_at
                    if access.starred_at
                    and access.starred_at >= split_message.created_at
                    else None,
                )
                for access in old_accesses
            ]
            models.ThreadAccess.objects.bulk_create(new_accesses, ignore_conflicts=True)

            # Copy labels
            for label in old_thread.labels.all():
                label.threads.add(new_thread)

            # Move messages
            messages_to_move.update(thread=new_thread)

            # Fix cross-thread parent references
            models.Message.objects.filter(
                thread=new_thread, parent__thread=old_thread
            ).update(parent=None)

            # Move ThreadEvents that belong chronologically to the new thread.
            # Two cases:
            #  - ThreadEvent attached to a moved message (FK `message`) — it
            #    must follow its message so that `event.thread == message.thread`
            #    stays coherent.
            #  - Free ThreadEvent (no message FK) created at or after the split
            #    point — symmetric with how messages are split, so the timeline
            #    follows the cut.
            event_ids = list(
                models.ThreadEvent.objects.filter(thread=old_thread)
                .filter(
                    Q(message__thread=new_thread)
                    | Q(
                        message__isnull=True,
                        created_at__gte=split_message.created_at,
                    )
                )
                .values_list("id", flat=True)
            )
            if event_ids:
                models.ThreadEvent.objects.filter(id__in=event_ids).update(
                    thread=new_thread
                )
                # Keep the `UserEvent.thread` denormalization in sync with
                # `thread_event.thread` — the ThreadViewSet annotations rely
                # on filtering UserEvent by `thread` directly to avoid a JOIN.
                models.UserEvent.objects.filter(thread_event_id__in=event_ids).update(
                    thread=new_thread
                )

            # Recalculate old thread snippet from its most recent remaining message
            last_remaining = old_thread.messages.order_by("-created_at").first()
            if last_remaining:
                old_thread.snippet = thread_snippet(
                    last_remaining.get_parsed_data(),
                    fallback=old_thread.subject or "",
                )
                old_thread.save(update_fields=["snippet"])

            # Update stats on both threads
            old_thread.update_stats()
            new_thread.update_stats()

            # Invalidate summaries
            models.Thread.objects.filter(id__in=[old_thread.id, new_thread.id]).update(
                summary=None
            )

        serializer = serializers.ThreadSerializer(
            new_thread,
            context={
                "request": request,
                "mailbox_id": request.query_params.get("mailbox_id"),
            },
        )
        return drf.response.Response(serializer.data, status=status.HTTP_201_CREATED)

    @extend_schema(
        tags=["threads"],
        request=serializers.ThreadBulkDeleteRequestSerializer,
        responses={
            200: OpenApiResponse(
                response={
                    "type": "object",
                    "properties": {
                        "success": {"type": "boolean"},
                        "deleted_count": {"type": "integer"},
                    },
                    "required": ["success", "deleted_count"],
                },
                description="Messages permanently deleted.",
            ),
            400: OpenApiResponse(
                response={
                    "type": "object",
                    "properties": {"detail": {"type": "string"}},
                },
                description="Missing or invalid parameters.",
            ),
            401: OpenApiResponse(
                description=(
                    "Authentication credentials were not provided or are invalid."
                ),
            ),
            403: OpenApiResponse(
                description=(
                    "You do not have permission to delete drafts in one or more "
                    "targeted threads."
                ),
            ),
        },
        description=(
            "Permanently delete (hard-delete) draft messages within the given "
            "accessible and editable threads. A thread emptied by the deletion is "
            "removed; otherwise its stats are recomputed."
        ),
    )
    @drf.decorators.action(
        detail=False,
        methods=["post"],
        url_path="bulk-delete",
        url_name="bulk-delete",
    )
    def bulk_delete(self, request):
        """Permanently delete draft messages in bulk.

        Unlike the soft-delete flag endpoint, this removes the message rows for
        good. It targets only the messages matching ``scope`` so reply-draft
        threads keep their other messages.
        """
        serializer = serializers.ThreadBulkDeleteRequestSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        scope = serializer.validated_data["scope"]
        thread_ids = serializer.validated_data["thread_ids"]
        message_ids = serializer.validated_data["message_ids"]

        # The scope is constrained to a known key by the serializer, so the
        # lookup is guaranteed to resolve.
        scope_filter = (
            serializers.ThreadBulkDeleteRequestSerializer.BULK_DELETE_SCOPE_FILTERS[
                scope
            ]
        )

        # Per-thread authorization: restrict to threads the user can fully edit
        # (EDITOR ThreadAccess + CAN_EDIT MailboxAccess), exactly like the flag
        # endpoint. The view-level HasThreadEditAccess only gates authentication
        # for this detail=False action, so the real check is this queryset scope.
        accessible_thread_ids = models.ThreadAccess.objects.editable_by(
            request.user
        ).values_list("thread_id", flat=True)

        with transaction.atomic():
            messages_to_delete = models.Message.objects.filter(
                thread_id__in=accessible_thread_ids,
                **scope_filter,
            )
            if thread_ids:
                messages_to_delete = messages_to_delete.filter(thread_id__in=thread_ids)
            if message_ids:
                messages_to_delete = messages_to_delete.filter(id__in=message_ids)

            affected_thread_ids = set(
                messages_to_delete.values_list("thread_id", flat=True)
            )
            # Count before deletion: the cascade total returned by delete() also
            # includes related rows (recipients, attachments), not just messages.
            deleted_count = messages_to_delete.count()
            messages_to_delete.delete()

            # An emptied thread is removed; a thread that still has messages has
            # its denormalized stats (has_draft, has_trashed, ...) recomputed so
            # it drops out of the corresponding folder filter.
            for thread in models.Thread.objects.filter(pk__in=affected_thread_ids):
                if thread.messages.exists():
                    thread.update_stats()
                else:
                    thread.delete()

        return drf.response.Response({"success": True, "deleted_count": deleted_count})
