"""
`/api/spaces/` and `/api/pages/` — the tree the sidebar draws.

Three things here are not CRUD and are the reason this phase is its own slice:

- **The tree comes back in ONE request.** `GET /api/spaces/` nests every page,
  because a sidebar with half a tree is not a partly-drawn sidebar, it is a
  broken one. It is therefore unpaginated, deliberately — see `list()`.
- **`PATCH /api/pages/{id}/settings/` MERGES.** Two tabs edit different
  sections of one page's settings, and a replace makes the second write delete
  the first one's section.
- **Reorder is one transaction per affected parent**, rewriting `position`.
"""

from django.db import transaction
from drf_spectacular.utils import extend_schema
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

from ..copiers import copy_page_rows
from ..models import Page, Space
from ..permissions import IsOwner
from ..serializers.workspace import (
    CopyPageSerializer,
    PageSerializer,
    ReorderSerializer,
    SettingsPatchSerializer,
    SpaceSerializer,
)


def _reordered(queryset, ids):
    """
    The rows of `queryset` in the order `ids` names, with anything unnamed kept
    and appended in its existing order. See `ReorderSerializer` for why.
    """
    by_id = {str(row.pk): row for row in queryset}
    moved = [by_id[i] for i in ids if i in by_id]
    seen = {row.pk for row in moved}
    return moved + [row for row in queryset if row.pk not in seen]


def _write_positions(rows):
    for index, row in enumerate(rows):
        if row.position != index:
            row.position = index
            row.save(update_fields=['position', 'updated_at'])


class SpaceViewSet(viewsets.ModelViewSet):
    """`/api/spaces/` — a user's spaces, each with its pages nested."""

    serializer_class = SpaceSerializer
    permission_classes = [IsAuthenticated, IsOwner]

    # Never used at runtime — `get_queryset` below replaces it. It is here so
    # `manage.py spectacular` can derive the model without a request, since the
    # committed schema is the only check against contract drift.
    queryset = Space.objects.none()

    # The sidebar needs the whole tree to render at all, so this one list is
    # unpaginated on purpose. It is bounded by a human: spaces and pages are
    # made by hand, a dozen of each is a lot, and the feature lists that really
    # can grow without limit keep the default paginator.
    pagination_class = None

    def get_queryset(self):
        return (
            Space.objects.filter(user=self.request.user)
            .prefetch_related('pages')
            .order_by('position', 'id')
        )

    def perform_create(self, serializer):
        with transaction.atomic():
            spaces = Space.objects.select_for_update().filter(user=self.request.user)
            last = spaces.order_by('-position').first()
            position = 0 if last is None else last.position + 1
            # The first space a user has is the one the app opens into; there
            # is nowhere else for it to start.
            is_default = serializer.validated_data.get('is_default', last is None)
            space = serializer.save(
                user=self.request.user, position=position, is_default=is_default
            )
            self._hold_one_default(space)

    def perform_update(self, serializer):
        with transaction.atomic():
            space = serializer.save()
            self._hold_one_default(space)

    def _hold_one_default(self, space):
        """
        Exactly one space per user carries `is_default`.

        Enforced here rather than as a partial unique index, which MySQL does
        not have. Clearing the others is the right direction: the client sends
        the space it wants, and a write that succeeded but left two defaults is
        a sidebar whose Main view depends on a tie-break.
        """
        if not space.is_default:
            return
        Space.objects.filter(user=space.user).exclude(pk=space.pk).filter(
            is_default=True
        ).update(is_default=False)

    @extend_schema(
        summary='Reorder a user\'s spaces.',
        request=ReorderSerializer,
        responses={200: SpaceSerializer(many=True)},
    )
    @action(detail=False, methods=['post'])
    def reorder(self, request):
        body = ReorderSerializer(data=request.data)
        body.is_valid(raise_exception=True)
        with transaction.atomic():
            rows = list(Space.objects.select_for_update().filter(user=request.user).order_by('position', 'id'))
            _write_positions(_reordered(rows, body.validated_data['ids']))
        return Response(self.get_serializer(self.get_queryset(), many=True).data)

    @extend_schema(
        summary='Reorder the pages inside one space.',
        request=ReorderSerializer,
        responses={200: SpaceSerializer},
    )
    @action(detail=True, methods=['post'], url_path='reorder-pages')
    def reorder_pages(self, request, pk=None):
        space = self.get_object()
        body = ReorderSerializer(data=request.data)
        body.is_valid(raise_exception=True)
        with transaction.atomic():
            rows = list(space.pages.select_for_update().order_by('position', 'id'))
            _write_positions(_reordered(rows, body.validated_data['ids']))
        return Response(self.get_serializer(self.get_object()).data)


class PageViewSet(viewsets.ModelViewSet):
    """`/api/pages/` — the pages themselves, plus settings and copy."""

    serializer_class = PageSerializer
    permission_classes = [IsAuthenticated, IsOwner]
    pagination_class = None
    queryset = Page.objects.none()  # schema generation only — see SpaceViewSet

    def get_queryset(self):
        # `space__user`, never `space` alone. A queryset filtered only by the
        # id in the URL is an IDOR that returns somebody else's page for a
        # guessed number, and it looks exactly like one that is not.
        return Page.objects.filter(space__user=self.request.user).order_by('position', 'id')

    @extend_schema(
        summary='Merge keys into a page\'s settings blob.',
        request=SettingsPatchSerializer,
        responses={200: PageSerializer},
    )
    # NOT named `settings`: `APIView.settings` is DRF's own api_settings, and
    # shadowing it makes every exception inside this viewset raise a second,
    # unrelated AttributeError out of the error handler.
    @action(detail=True, methods=['patch'], url_path='settings', url_name='settings')
    def page_settings(self, request, pk=None):
        """
        MERGE, NOT REPLACE, one level deep.

        Two tabs edit different sections of one page's settings — properties in
        one, tags in the other — and a replace makes whichever saves second
        delete the other's section. One level is the right depth: the sections
        are top-level keys, and a deep merge cannot express deleting anything
        inside one (a list of tags whose members merged by index would be
        unfixable). A `null` value deletes its key, which is how a client
        clears one.
        """
        body = SettingsPatchSerializer(data=request.data)
        body.is_valid(raise_exception=True)
        with transaction.atomic():
            page = Page.objects.select_for_update().get(pk=self.get_object().pk)
            merged = dict(page.settings or {})
            for key, value in body.validated_data.items():
                if value is None:
                    merged.pop(key, None)
                else:
                    merged[key] = value
            page.settings = merged
            page.save(update_fields=['settings', 'updated_at'])
        return Response(self.get_serializer(page).data)

    @extend_schema(
        summary='Copy a page into a space.',
        request=CopyPageSerializer,
        responses={201: PageSerializer},
    )
    @action(detail=True, methods=['post'])
    def copy(self, request, pk=None):
        """
        Mirrors `app/pageCopy.js` and `SpacesProvider.copyPageInto`.

        The copy keeps the source's icon and banner — a copy arriving in the
        type's default colours does not read as a copy of anything — and is
        NEITHER a favourite NOR locked: favouriting is deliberate and would
        silently double an entry in the Favourites section, and a lock exists to
        stop you editing a finished list by accident, which a page you just made
        to work in is the opposite of.

        The response says what actually landed (`copied`), because the two
        halves are independent: a type whose rows cannot be copied must not make
        the settings copy look like it failed too.
        """
        source = self.get_object()
        body = CopyPageSerializer(data=request.data)
        body.is_valid(raise_exception=True)
        data = body.validated_data

        space = source.space
        if 'spaceId' in data:
            space = Space.objects.filter(user=request.user, pk=data['spaceId']).first()
            if space is None:
                return Response(
                    {'detail': 'No such space.'}, status=status.HTTP_400_BAD_REQUEST
                )

        with transaction.atomic():
            last = space.pages.order_by('-position').first()
            copy = Page.objects.create(
                space=space,
                type_id=source.type_id,
                name=data.get('name') or source.name,
                icon=source.icon,
                # An image banner's bytes never left the machine that picked
                # them, so the copy would keep a setting pointing at nothing.
                banner=None if (source.banner or {}).get('type') == 'image' else source.banner,
                favorite=False,
                locked=False,
                position=0 if last is None else last.position + 1,
                settings=dict(source.settings or {}) if data['setup'] else {},
            )
            entries = copy_page_rows(source, copy) if data['entries'] else False

        payload = self.get_serializer(copy).data
        payload['copied'] = {'entries': entries, 'settings': bool(data['setup'])}
        return Response(payload, status=status.HTTP_201_CREATED)
