# 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: base infrastructure."""

import copy
import logging
from collections.abc import Callable
from typing import (
    Any,
    Protocol,
    Self,
    TYPE_CHECKING,
    TypeAlias,
    override,
    runtime_checkable,
)

from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Model, QuerySet
from django.http import Http404, HttpRequest, HttpResponseBase
from rest_framework import status, viewsets
from rest_framework.filters import BaseFilterBackend
from rest_framework.generics import (
    CreateAPIView,
    DestroyAPIView,
    GenericAPIView,
    ListAPIView,
    RetrieveAPIView,
    UpdateAPIView,
)
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import BaseSerializer
from rest_framework.views import APIView

from debusine.db.context import ContextConsistencyError, context
from debusine.db.models import Workspace
from debusine.db.models.permissions import (
    anonymous_access,
    format_permission_check_error,
)
from debusine.db.permissioncontext import PermissionContext, PermissionUser
from debusine.server.exceptions import (
    DebusineAPIException,
    raise_workspace_not_found,
)
from debusine.server.views.rest import ProblemResponse

if TYPE_CHECKING:
    from rest_framework.permissions import _PermissionClass

    _PermissionClass  # fake usage for vulture
    CreateAPIViewBase: TypeAlias = CreateAPIView
    DestroyAPIViewBase: TypeAlias = DestroyAPIView
    GenericAPIViewBase: TypeAlias = GenericAPIView
    ListAPIViewBase: TypeAlias = ListAPIView
    RetrieveAPIViewBase: TypeAlias = RetrieveAPIView
    UpdateAPIViewBase: TypeAlias = UpdateAPIView
    GenericViewSetBase: TypeAlias = viewsets.GenericViewSet
else:
    # REST framework's generic views don't support generic types at run-time
    # yet.
    from debusine.utils.typing_utils import patch_runtime_generic_support

    CreateAPIViewBase = patch_runtime_generic_support(CreateAPIView)
    DestroyAPIViewBase = patch_runtime_generic_support(DestroyAPIView)
    GenericAPIViewBase = patch_runtime_generic_support(GenericAPIView)
    GenericViewSetBase = patch_runtime_generic_support(viewsets.GenericViewSet)
    ListAPIViewBase = patch_runtime_generic_support(ListAPIView)
    RetrieveAPIViewBase = patch_runtime_generic_support(RetrieveAPIView)
    UpdateAPIViewBase = patch_runtime_generic_support(UpdateAPIView)


class BaseAPIView(APIView):
    """Common base for API views."""

    permissions_checked: bool = False

    def enforce(self, predicate: Callable[[PermissionContext], bool]) -> None:
        """Enforce a permission predicate."""
        self.permissions_checked = True
        if predicate(context.pc):
            return

        raise DebusineAPIException(
            title=format_permission_check_error(predicate, context.pc),
            status_code=status.HTTP_403_FORBIDDEN,
        )

    @override
    def dispatch(
        self,
        request: HttpRequest,
        *args: Any,
        **kwargs: Any,
    ) -> HttpResponseBase:
        response = super().dispatch(request, *args, **kwargs)
        if 200 <= response.status_code < 300 and not self.permissions_checked:
            raise AssertionError("View does not enforce permission checks.")
        return response

    def set_current_workspace(
        self,
        workspace: str | Workspace,
    ) -> None:
        """Set the current workspace in context."""
        if isinstance(workspace, str):
            try:
                workspace = Workspace.objects.get_for_context(name=workspace)
            except Workspace.DoesNotExist:
                raise_workspace_not_found(workspace)
        try:
            workspace.set_current()
        except ContextConsistencyError:
            # Turn exception in a 404 response.
            # 404 is used instead of 403 as an attempt to prevent leaking which
            # private workspace exists that the user cannot see
            logging.debug("permission denied on %s reported as 404", workspace)
            raise_workspace_not_found(workspace)


class GetOrCreateAPIView[M: Model](CreateAPIViewBase[M], BaseAPIView):
    """Class to create a new object (return 201 created) or return 200."""

    @override
    def get_object(self) -> M:
        """Override to return more API-friendly errors."""
        try:
            return super().get_object()
        except Http404 as exc:
            raise DebusineAPIException(
                title=str(exc), status_code=status.HTTP_404_NOT_FOUND
            )

    def get_existing_object(self, serializer: BaseSerializer[M]) -> M:
        """
        If there is an existing object matching Serializer, return it.

        If not, raise ObjectDoesNotExist.
        """
        return self.get_queryset().get(**serializer.validated_data)

    @override
    def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
        """
        Return the object with HTTP 201 (created) or HTTP 200 (already existed).

        It is a modification of CreateAPIView.create().

        The default behaviour in rest_framework when an object cannot be
        inserted because it already exists is to return HTTP 400. In
        GetOrCreateApiView it returns HTTP 200 and the client knows that
        the object that wanted to create was already in the database.
        """
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        response_status: int
        try:
            obj = self.get_existing_object(serializer)
            response_status = status.HTTP_200_OK
            pk = obj.pk
        except ObjectDoesNotExist:
            self.perform_create(serializer)
            response_status = status.HTTP_201_CREATED
            assert serializer.instance is not None
            pk = serializer.instance.pk

        headers = self.get_success_headers(serializer.data)

        response_data = copy.deepcopy(serializer.data)
        response_data["id"] = pk

        return Response(response_data, status=response_status, headers=headers)


@runtime_checkable
class SupportsCanDisplay(Protocol):
    """A query set that supports `can_display` filtering."""

    def can_display(self, user: PermissionUser) -> Self:
        """Keep only objects that can be displayed."""


class CanDisplayFilterBackend(BaseFilterBackend):
    """Filter that only allows objects that can be displayed."""

    @override
    def filter_queryset[M: Model, Row = Model](
        self,
        request: Request,  # noqa: ARG002, U100
        queryset: QuerySet[M, Row],
        view: APIView,  # noqa: ARG002, U100
    ) -> QuerySet[M, Row]:
        """Keep only objects that can be displayed."""
        assert isinstance(queryset, SupportsCanDisplay)
        # This may be called in contexts where no user is set: for example,
        # some tasks need to call views using this filter to list artifact
        # relations, in which case the request is authenticated using a
        # worker token and context.user is unset.  The permission_filter
        # decorator means that it's actually OK to call
        # Workspace.objects.can_display with None, but for some reason the
        # type checker doesn't understand that.
        return queryset.can_display(context.pc)  # type: ignore[arg-type]


class Whoami(BaseAPIView):
    """Simple view that returns the authentication status."""

    def get(self, request: Request) -> Response:
        """Return information about the current user."""
        self.enforce(anonymous_access)
        user: str
        if request.user.is_authenticated:
            user = request.user.username
        else:
            user = "<anonymous>"
        work_request: int | None = None
        if context.pc.work_request:
            work_request = context.pc.work_request.id
        return Response(
            {
                "user": user,
                "auth": bool(request.auth),
                "work_request": work_request,
                "worker": bool(context.pc.worker_token),
            }
        )


class SupersededAPIView(APIView):
    """
    Simple view that replaces a removed API endpoint.

    To use it, mount it in a URL conf with optional hints for the replacement::

        path(
            "superseded-endpoint/",
            SupersededAPIView.as_view(),
            {"new_endpoint": "/1.0/new-endpoint/", "client_version": "1.0"},
        )
    """

    @override
    def dispatch(
        self,
        request: HttpRequest,
        *args: Any,
        new_endpoint: str | None = None,
        client_version: str | None = None,
        **kwargs: Any,
    ) -> HttpResponseBase:
        """Configure the instance and dispatch a respnose."""
        self.new_endpoint = new_endpoint
        self.client_version = client_version

        if request.method in ("GET", "POST", "PATCH", "DELETE"):
            return self.error_response(new_endpoint, client_version)
        return super().dispatch(request, *args, **kwargs)

    def error_response(
        self, new_endpoint: str | None = None, client_version: str | None = None
    ) -> HttpResponseBase:
        """Return the view's superseded error."""
        detail = "This endpoint is no longer supported."
        if new_endpoint:
            detail += f" It has been replaced by {new_endpoint}"
            if client_version:
                detail += (
                    f", which is supported by debusine-client (>= "
                    f"{client_version}). "
                    f"Please upgrade your client"
                )
            detail += "."

        return ProblemResponse(title="Superseded API Endpoint", detail=detail)
