"""
The three behaviours every feature viewset repeats.

Written once here so that nine features cannot implement them nine ways —
which matters most for the first one, since a list endpoint that forgets the
user filter is an IDOR and looks exactly like one that does not.

All three are built as of Phase 3, which is the first slice with rows to scope,
to bulk-replace or to sample. A feature viewset sets `bulk_key` and
`sample_factory` and inherits the rest.
"""

from django.db import transaction
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

from .models import Page
from .permissions import IsOwner


class PageScopedViewSet(viewsets.ModelViewSet):
    """
    The base every page-scoped feature viewset inherits.

    ONE QUERYSET, AND IT FILTERS BY THE USER, NOT BY THE PAGE. `?page=` alone
    names a row somebody else may own — page ids are sequential integers, so a
    list endpoint that trusts the parameter hands another account's rows to
    anyone who can count. The user filter is written here so that no feature
    can forget it; `IsOwner` is only the backstop on detail routes.

    A subclass sets `queryset` (or overrides `get_queryset` and calls up) and
    nothing else about scoping.
    """

    permission_classes = [IsAuthenticated, IsOwner]

    # The list query parameter every feature's `data/api.js` already sends.
    page_query_param = 'page'

    def get_queryset(self):
        qs = super().get_queryset().filter(page__space__user=self.request.user)
        page_id = self.request.query_params.get(self.page_query_param)
        if page_id is None:
            # Detail routes address a row directly and are already bounded by
            # the user filter above. A LIST without a page is the one that is
            # not — it would return every row the user owns, across every page.
            if self.action == 'list':
                raise ValidationError({self.page_query_param: 'This query parameter is required.'})
            return qs
        return qs.filter(page_id=page_id)

    def get_page(self):
        """
        The `?page=` page, as an object the caller owns, or 404.

        Used by writes: a row is created against a page, and the page has to be
        resolved the same way the list filter resolves it or the two disagree
        about what `?page=` means.
        """
        page_id = self.request.query_params.get(self.page_query_param)
        if page_id is None:
            raise ValidationError({self.page_query_param: 'This query parameter is required.'})
        page = Page.objects.filter(pk=page_id, space__user=self.request.user).first()
        if page is None:
            # 404 rather than 403: whether a page id exists is not a question
            # this endpoint answers for a user who does not own it.
            raise NotFound('No such page.')
        return page


class BulkReplaceMixin:
    """
    `PUT /api/<x>/bulk/?page=<pageId>` — replace the page's whole list.

    ONE TRANSACTION, ALL OR NOTHING. This is the primitive behind clear, fill
    and undo, and undo restores a whole-list SNAPSHOT: a partial application
    leaves the page in a state the user never created and cannot undo again.

    THE BODY IS WRAPPED, one key per feature — `{tasks: [...]}`,
    `{events: [...]}`, `{transactions: [...]}`, `{sessions: [...]}` — because
    that is what the client sends. A bare list is rejected rather than guessed
    at: a top-level array leaves no room for the endpoint to ever say anything
    alongside the rows, and every client already wraps.

    The response is a bare list of the rows as they now exist. `data/api.js`
    reads `body.results ?? body`, so an envelope would work too — a list is
    returned because there is nothing here to paginate: the caller just sent
    the whole set.

    A subclass sets `bulk_key`.
    """

    bulk_key = None

    @extend_schema(
        summary='Replace a page\'s whole list in one transaction.',
        parameters=[OpenApiParameter('page', str, OpenApiParameter.QUERY, required=True)],
        request=None,
        responses=None,
    )
    @action(detail=False, methods=['put'])
    def bulk(self, request):
        page = self.get_page()
        rows = request.data.get(self.bulk_key) if isinstance(request.data, dict) else None
        if rows is None:
            raise ValidationError({self.bulk_key: f'Send the rows wrapped as {{"{self.bulk_key}": [...]}}.'})
        if not isinstance(rows, list):
            raise ValidationError({self.bulk_key: 'Expected a list of rows.'})

        # The page is fixed by `?page=`, not by whatever each row claims. A row
        # naming another page in a bulk body would otherwise write into a list
        # the caller did not name and did not snapshot.
        payload = [{**row, 'page': page.pk} for row in rows]
        serializer = self.get_serializer(data=payload, many=True)
        serializer.is_valid(raise_exception=True)

        with transaction.atomic():
            self.filter_queryset(self.get_queryset()).filter(page=page).delete()
            serializer.save()

        return Response(serializer.data)


class SampleMixin:
    """
    `GET /api/<x>/sample/` — the example set, as rows that are NOT saved.

    The server owns example data because the same set has to seed a page
    created on another machine, and a renderer's fixture cannot do that. It is
    not page-scoped and takes no `?page=`: the client asks for the set and then
    writes it through `bulk` against the page it means.

    **Dates are generated relative to today, at request time.** A fixture with
    absolute dates rots into a screen of stale rows — which is what a first-run
    user would see, since this is what "Fill with example data" loads.

    A subclass sets `sample_factory` to a callable returning wire-shaped dicts,
    wrapped in `staticmethod()` — a plain function assigned to a class attribute
    becomes a bound method and would be handed `self` as its first argument.
    """

    sample_factory = None

    @extend_schema(summary='Example rows, dated relative to today.', responses=None)
    @action(detail=False, methods=['get'])
    def sample(self, request):
        return Response(self.sample_factory())
