"""
The wire shape of the workspace tree.

TWO THINGS ABOUT IT THAT ARE NOT DRF DEFAULTS.

**Field names are camelCase** (`typeId`, `isDefault`, `spaceId`). Every
`features/<x>/data/api.js` already maps between the client's shape and the
wire's, and the client half of this tree — `SpacesProvider` — is plain
JavaScript objects with those exact keys. Renaming them here would mean a
`fromWire` map for a file that does not have one.

**`id` is serialised as a STRING**, not an integer. The client's ids are
strings today (`uid()`, and `'tasks'` for the seeded pages), and they are used
as URL segments, `Map` keys and `useTabState` key fragments. A page id that
arrives as a number and comes back out of a URL as a string stops matching
itself in the one index every lookup in the app goes through. The routes still
resolve an integer pk; only the representation is a string.
"""

from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers

from ..models import PAGE_TYPE_IDS, Page, Space


@extend_schema_field(serializers.CharField())
class SpaceIdField(serializers.PrimaryKeyRelatedField):
    """
    A space reference that reads back as a string, for the reason above.

    The `extend_schema_field` is not decoration. `drf-spectacular` types a
    `PrimaryKeyRelatedField` from the target model's pk, so the committed schema
    declared `spaceId: integer` while this field has always *sent* a string —
    and the schema is the one artefact that is supposed to make a wire mismatch
    show up in a diff rather than at runtime. Only `to_representation` is
    overridden because the read direction is the one that differs: DRF's
    `to_internal_value` already resolves a numeric string.
    """

    def to_representation(self, value):
        return str(super().to_representation(value))


class PageSerializer(serializers.ModelSerializer):
    """One page, as it appears both nested in the tree and on its own routes."""

    id = serializers.CharField(read_only=True)
    spaceId = SpaceIdField(source='space', queryset=Space.objects.none())
    typeId = serializers.CharField(source='type_id')

    class Meta:
        model = Page
        fields = [
            'id',
            'spaceId',
            'typeId',
            'name',
            'icon',
            'banner',
            'favorite',
            'locked',
            'position',
            'settings',
        ]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # A page may only be created in — or moved to — a space the caller
        # owns. Writing this as the field's queryset rather than as a
        # `validate_spaceId` keeps it out of reach of a view that forgets to
        # call one, which is the same argument `PageScopedViewSet` makes.
        request = self.context.get('request')
        if request is not None and request.user.is_authenticated:
            self.fields['spaceId'].queryset = Space.objects.filter(user=request.user)

    def validate_typeId(self, value):
        if value not in PAGE_TYPE_IDS:
            raise serializers.ValidationError(f'Unknown page type: {value}.')
        return value

    def validate_settings(self, value):
        # The server never interprets the blob, but it does insist it is one:
        # a list or a string here would break the merge in `settings/`.
        if not isinstance(value, dict):
            raise serializers.ValidationError('settings must be an object.')
        return value

    def create(self, validated_data):
        # Append. A client that cares about placement reorders afterwards;
        # every other caller means "at the end of the space".
        space = validated_data['space']
        validated_data.setdefault('position', _next_position(space.pages))
        return super().create(validated_data)


class NestedPageSerializer(PageSerializer):
    """
    The pages inside `GET /api/spaces/`.

    `spaceId` is dropped: it is the id of the object the page is nested in, and
    a tree that repeats its own edges invites the two to disagree.
    """

    class Meta(PageSerializer.Meta):
        fields = [f for f in PageSerializer.Meta.fields if f != 'spaceId']


class SpaceSerializer(serializers.ModelSerializer):
    """
    A space and its pages.

    The pages are NESTED AND READ-ONLY here. `GET /api/spaces/` returns the
    whole tree in one request because the sidebar needs all of it to render at
    all — but writing a space and its pages in one body would give the client
    two ways to create a page, and the bulk path is the one that can half-apply.
    Pages are written through `/api/pages/`.
    """

    id = serializers.CharField(read_only=True)
    isDefault = serializers.BooleanField(source='is_default', required=False)
    pages = NestedPageSerializer(many=True, read_only=True)

    class Meta:
        model = Space
        fields = ['id', 'name', 'favorite', 'isDefault', 'position', 'pages']


class SettingsPatchSerializer(serializers.Serializer):
    """Body of `PATCH /api/pages/{id}/settings/` — the keys to merge in."""

    def to_internal_value(self, data):
        if not isinstance(data, dict):
            raise serializers.ValidationError('Send an object of settings keys.')
        return data


class CopyPageSerializer(serializers.Serializer):
    """
    Body of `POST /api/pages/{id}/copy/`, mirroring `app/pageCopy.js`.

    `setup` (the settings blob) defaults ON and `entries` (the rows) OFF:
    wanting the shape of a page without last quarter's transactions is the
    common case, and it is what the New page dialog's two switches default to.
    """

    spaceId = serializers.CharField(required=False)
    name = serializers.CharField(max_length=120, required=False, allow_blank=False)
    entries = serializers.BooleanField(required=False, default=False)
    setup = serializers.BooleanField(required=False, default=True)


class ReorderSerializer(serializers.Serializer):
    """
    Body of both reorder endpoints — the ids in their NEW order.

    Ids the caller does not mention are KEPT, appended in their existing order,
    which is what makes this safe to call with a SUBSET. The sidebar drags
    favourite and non-favourite spaces as two separate lists, so a drag inside
    one group only ever names that group's ids; and a page created in another
    window while a drag was in flight must not be deleted by the drop.
    """

    ids = serializers.ListField(child=serializers.CharField(), allow_empty=True)


def _next_position(manager):
    last = manager.order_by('-position').first()
    return 0 if last is None else last.position + 1
