# Copyright © The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Views for the server application: collection relations."""

import hashlib
from typing import Any, override
from urllib.parse import urlencode

from django.db import IntegrityError, transaction
from django.db.models import QuerySet
from django.urls import reverse
from rest_framework import mixins, status
from rest_framework.parsers import JSONParser
from rest_framework.request import Request
from rest_framework.response import Response

from debusine.client.models import LookupChildType
from debusine.db.context import context
from debusine.db.models import Collection, CollectionRelation, User, Workspace
from debusine.db.permissioncontext import PermissionContext
from debusine.server.collections.lookup import lookup_single
from debusine.server.exceptions import DebusineAPIException
from debusine.server.serializers import (
    CollectionIdentifierSerializer,
    CollectionRelationSerializer,
    CollectionRelationSetSerializer,
)
from debusine.server.views.base import BaseAPIView, GenericViewSetBase
from debusine.server.views.rest import IsUserTokenAuthenticated


class CollectionRelationListView(
    BaseAPIView,
    mixins.ListModelMixin,
    GenericViewSetBase[CollectionRelation],
):
    """
    List relations, optionally filtered by source, target, and type.

    If filtered by source and type: return one page with all the relations
    and include the ETag header. Otherwise: paginates without the ETag header.
    """

    permission_classes = [IsUserTokenAuthenticated]
    parser_classes = [JSONParser]
    serializer_class = CollectionRelationSerializer

    _etag: str | None  # set only when forcing a single page

    @override
    def initial(self, request: Request, *args: Any, **kwargs: Any) -> None:
        """Set current workspace."""
        super().initial(request, *args, **kwargs)
        self.set_current_workspace(kwargs["workspace"])
        self._etag = None

    def _force_one_page(self) -> bool:
        """If "source" and "type" in query_params: return True."""
        query_params = self.request.GET
        return "source" in query_params and "type" in query_params

    @override
    def paginate_queryset[Row](
        self,
        queryset: QuerySet[CollectionRelation, Row],
    ) -> list[Row] | None:
        """Use one page when specifying "source" and "type" parameters."""
        if self._force_one_page():
            assert self.paginator is not None
            assert hasattr(self.paginator, "page_size")
            self.paginator.page_size = max(queryset.count(), 1)

        return super().paginate_queryset(queryset)

    @override
    def get_queryset(self) -> QuerySet[CollectionRelation]:
        """List collection relations in a workspace."""
        workspace = context.require_workspace()
        pc = context.pc
        assert isinstance(pc.user, User)

        self.enforce(workspace.can_display)

        query_params = self.request.GET

        source_collection = None
        target_collection = None

        if (source_query_param := query_params.get("source")) is not None:
            source_collection = _lookup_collection(
                source_query_param, workspace=workspace, pc=pc
            )
            self.enforce(source_collection.can_display)

        if (target_query_param := query_params.get("target")) is not None:
            target_collection = _lookup_collection(
                target_query_param, workspace=workspace, pc=pc
            )
            self.enforce(target_collection.can_display)

        queryset = _get_queryset(
            source_workspace=workspace,
            source=source_collection,
            target=target_collection,
            relation_type=query_params.get("type"),
        )

        if self._force_one_page():
            self._etag = _calculate_etag(queryset)

        return queryset

    @override
    def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
        """List the relationships."""
        response = super().list(request, *args, **kwargs)

        if self._etag is not None:
            response["ETag"] = f'"{self._etag}"'

        return response


class CollectionRelationSetView(
    BaseAPIView,
):
    """Replace relation targets for one source collection and relation type."""

    permission_classes = [IsUserTokenAuthenticated]

    def put(self, request: Request, *args: Any, **kwargs: Any) -> Response:
        """Update set of relations of specific type from a collection."""
        self.set_current_workspace(kwargs["workspace"])
        workspace_model = context.require_workspace()
        self.enforce(workspace_model.can_display)

        pc = context.pc
        assert isinstance(pc.user, User)

        _validate_relation_type(kwargs["relation_type"])
        relation_type = CollectionRelation.Types(kwargs["relation_type"])

        source = _lookup_collection(
            kwargs["collection"], workspace=workspace_model, pc=pc
        )
        self.enforce(source.can_create_collection_relation)

        serializer = CollectionRelationSetSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        with transaction.atomic():
            target_models = []

            for target in serializer.validated_data["targets"]:
                try:
                    target_models.append(
                        workspace_model.get_collection(
                            pc=pc,
                            name=target["name"],
                            category=target["category"],
                        )
                    )
                except Collection.DoesNotExist:
                    collection_lookup = f"{target["name"]}@{target["category"]}"
                    raise DebusineAPIException(
                        title=(
                            f'Collection {collection_lookup!r} does not exist '
                            'or is not accessible'
                        ),
                        status_code=status.HTTP_400_BAD_REQUEST,
                    )

            source = Collection.objects.select_for_update().get(pk=source.pk)

            if_match = self.request.headers.get("If-Match")

            if if_match is not None:
                list_url = (
                    reverse(
                        "api:collection-relation-list",
                        kwargs={"workspace": workspace_model.name},
                    )
                    + "?"
                    + urlencode(
                        {
                            "source": f"{source.name}@{source.category}",
                            "type": relation_type,
                        }
                    )
                )

                current_etag = _calculate_etag(
                    _get_queryset(
                        source_workspace=source.workspace,
                        source=source,
                        relation_type=relation_type,
                    )
                )

                if f'"{current_etag}"' != if_match:
                    raise DebusineAPIException(
                        title="Collection relations changed",
                        detail=(
                            'The "If-Match" header does not match the '
                            f'current ETag. Fetch the current ETag from '
                            f'"{list_url}" and retry.'
                        ),
                        status_code=status.HTTP_412_PRECONDITION_FAILED,
                    )

            CollectionRelation.objects.filter(
                source=source, type=relation_type
            ).delete()

            try:
                response_targets = []

                for position, target_model in enumerate(target_models, start=1):
                    collection_relation_position = (
                        position
                        if relation_type
                        == CollectionRelation.Types.SUITE_REQUIRES
                        else None
                    )
                    collection_relation = CollectionRelation.objects.create(
                        source=source,
                        target=target_model,
                        type=relation_type,
                        position=collection_relation_position,
                        created_by_user=pc.user,
                    )
                    response_targets.append(
                        CollectionIdentifierSerializer(
                            {
                                "name": collection_relation.target.name,
                                "category": collection_relation.target.category,
                            }
                        ).data
                    )
            except IntegrityError as exc:
                raise DebusineAPIException(
                    title="Invalid relation targets",
                    detail=str(exc),
                    status_code=status.HTTP_400_BAD_REQUEST,
                ) from exc

        serializer = CollectionRelationSetSerializer(
            {"targets": response_targets}
        )

        return Response(
            serializer.data,
            status=status.HTTP_200_OK,
        )


def _lookup_collection(
    collection_identifier: str,
    *,
    workspace: Workspace,
    pc: PermissionContext,
) -> Collection:
    if "/" in collection_identifier:
        raise DebusineAPIException(
            title="Invalid collection identifier in query parameter",
            detail=(
                f"Collection identifier {collection_identifier!r} "
                "must not contain '/'"
            ),
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    try:
        result = lookup_single(
            collection_identifier,
            workspace,
            pc=pc,
            expect_type=LookupChildType.COLLECTION,
        )
    except KeyError:
        raise DebusineAPIException(
            title="Collection not found",
            detail=(
                f"Collection {collection_identifier!r} does not exist "
                f"in workspace {workspace.name!r}"
            ),
            status_code=status.HTTP_404_NOT_FOUND,
        )
    except LookupError as exc:
        raise DebusineAPIException(
            title="Invalid collection identifier in query parameter",
            detail=str(exc),
            status_code=status.HTTP_400_BAD_REQUEST,
        ) from exc

    return result.collection


def _validate_relation_type(relation_type: str) -> None:
    if relation_type not in CollectionRelation.Types.values:
        raise DebusineAPIException(
            title="Invalid relation type",
            detail=f"Unknown relation type: {relation_type!r}",
            status_code=status.HTTP_400_BAD_REQUEST,
        )


def _calculate_etag(collection_relations: QuerySet[CollectionRelation]) -> str:
    """Return a stable ETag for the ordered relation set."""
    h = hashlib.sha256()

    for cr in collection_relations:
        h.update(
            f"{cr.source.id}|{cr.target.id}|{cr.type}|{cr.position}\n".encode()
        )

    return h.hexdigest()


def _get_queryset(
    *,
    source_workspace: Workspace,
    source: Collection | None = None,
    relation_type: str | None = None,
    target: Collection | None = None,
) -> QuerySet[CollectionRelation]:
    queryset = CollectionRelation.objects.filter(
        source__workspace=source_workspace
    ).select_related("source", "target")

    if source is not None:
        queryset = queryset.filter(source=source)

    if target is not None:
        queryset = queryset.filter(target=target)

    if relation_type is not None:
        _validate_relation_type(relation_type)
        queryset = queryset.filter(type=relation_type)

    queryset = queryset.order_by(
        "source__category",
        "source__name",
        "type",
        "position",
        "target__category",
        "target__name",
        "id",
    )

    return queryset
