# 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 logging
from collections.abc import Callable, Iterable
from typing import (
    Any,
    Protocol,
    Self,
    TYPE_CHECKING,
    TypeVar,
    runtime_checkable,
)

from django.db.models import Model, QuerySet
from django.http import JsonResponse
from rest_framework import status
from rest_framework.exceptions import PermissionDenied as DRFPermissionDenied
from rest_framework.filters import BaseFilterBackend
from rest_framework.generics import (
    CreateAPIView,
    DestroyAPIView,
    GenericAPIView,
    ListAPIView,
    RetrieveAPIView,
    UpdateAPIView,
)
from rest_framework.permissions import BasePermission, DjangoModelPermissions
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView

from debusine.db.context import ContextConsistencyError, context
from debusine.db.models import Token, Workspace
from debusine.db.models.permissions import (
    PermissionUser,
    format_permission_check_error,
)
from debusine.server.exceptions import (
    DebusineAPIException,
    raise_workspace_not_found,
)

if TYPE_CHECKING:
    from rest_framework.permissions import _PermissionClass

    _PermissionClass  # fake usage for vulture
    CreateAPIViewBase = CreateAPIView
    DestroyAPIViewBase = DestroyAPIView
    GenericAPIViewBase = GenericAPIView
    ListAPIViewBase = ListAPIView
    RetrieveAPIViewBase = RetrieveAPIView
    UpdateAPIViewBase = UpdateAPIView
else:
    # REST framework's generic views don't support generic types at run-time
    # yet.
    class _CreateAPIViewBase:
        def __class_getitem__(*args):
            return CreateAPIView

    class _DestroyAPIViewBase:
        def __class_getitem__(*args):
            return DestroyAPIView

    class _GenericAPIViewBase:
        def __class_getitem__(*args):
            return GenericAPIView

    class _ListAPIViewBase:
        def __class_getitem__(*args):
            return ListAPIView

    class _RetrieveAPIViewBase:
        def __class_getitem__(*args):
            return RetrieveAPIView

    class _UpdateAPIViewBase:
        def __class_getitem__(*args):
            return UpdateAPIView

    CreateAPIViewBase = _CreateAPIViewBase
    DestroyAPIViewBase = _DestroyAPIViewBase
    GenericAPIViewBase = _GenericAPIViewBase
    ListAPIViewBase = _ListAPIViewBase
    RetrieveAPIViewBase = _RetrieveAPIViewBase
    UpdateAPIViewBase = _UpdateAPIViewBase


class ProblemResponse(JsonResponse):
    """
    Holds a title and other optional fields to return problems to the client.

    Follows RFC7807 (https://www.rfc-editor.org/rfc/rfc7807#section-6.1)
    """

    def __init__(
        self,
        title: str,
        detail: str | None = None,
        validation_errors: Iterable[Any] | None = None,
        status_code: int = status.HTTP_400_BAD_REQUEST,
    ) -> None:
        """
        Initialize object.

        :param title: included in the response data.
        :param detail: if not None, included in the response data.
        :param validation_errors: if not None, included in the response data.
        :param status_code: HTTP status code for the response.
        """
        data: dict[str, Any] = {"title": title}

        if detail is not None:
            data["detail"] = detail

        if validation_errors is not None:
            data["validation_errors"] = validation_errors

        super().__init__(
            data, status=status_code, content_type="application/problem+json"
        )


class IsTokenAuthenticated(BasePermission):
    """Allow access to requests with a valid token."""

    def has_permission(
        self, request: Request, view: APIView  # noqa: U100
    ) -> bool:
        """Return True if the request is authenticated with a Token."""
        return isinstance(request.auth, Token) and request.auth.enabled


class IsTokenUserAuthenticated(IsTokenAuthenticated):
    """Allow access if the request has an enabled Token with associated user."""

    def has_permission(
        self, request: Request, view: APIView  # noqa: U100
    ) -> bool:
        """Return True if valid token has a User assigned."""
        return (
            isinstance(request.auth, Token)
            and request.auth.enabled
            and request.auth.user is not None
        )


class IsTokenUserAuthenticatedDjangoModelPermissions(DjangoModelPermissions):
    """
    Allow access if a Token-authenticated user has model permissions.

    This is similar to :py:class:`DjangoModelPermissions`, except that it
    checks Token authentication.
    """

    def has_permission(self, request: Request, view: APIView) -> bool:
        """Return True if the user has the right permissions."""
        token = request.auth
        if not token or not token.user:
            return False

        queryset = self._queryset(view)
        assert request.method is not None
        perms = self.get_required_permissions(request.method, queryset.model)

        if not token.user.has_perms(perms):
            # Raise this rather than the default NotAuthenticated exception
            # when an authenticator fails; in this case the user is
            # authenticated, but doesn't have the right permissions.
            raise DRFPermissionDenied()

        return True


class IsWorkerAuthenticated(IsTokenAuthenticated):
    """Allow access to requests with a token assigned to a worker."""

    def has_permission(
        self, request: Request, view: APIView  # noqa: U100
    ) -> bool:
        """
        Return True if the request is an authenticated worker.

        The Token must exist in the database and have a Worker.
        """
        if not super().has_permission(request, view):
            # No token authenticated: no Worker Authenticated
            return False

        if not hasattr(request.auth, "worker"):
            # request.auth is None; or it's a Token without a "worker"
            return False

        return True


class IsGet(BasePermission):
    """Allow access if the request's method is GET."""

    def has_permission(
        self, request: Request, view: APIView  # noqa: U100
    ) -> bool:
        """Return True if request.method == "GET"."""
        return request.method == "GET"


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

    def enforce(self, predicate: Callable[[PermissionUser], bool]) -> None:
        """Enforce a permission predicate."""
        if predicate(context.user):
            return

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

    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)


_M = TypeVar("_M", bound=Model, covariant=True)


@runtime_checkable
class SupportsCanDisplay(Protocol[_M]):
    """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."""

    def filter_queryset(
        self,
        request: Request,  # noqa: U100
        queryset: QuerySet[_M],
        view: APIView,  # noqa: U100
    ) -> QuerySet[_M]:
        """Keep only objects that can be displayed."""
        assert isinstance(queryset, SupportsCanDisplay)
        return queryset.can_display(context.require_user())


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

    def get(self, request: Request) -> Response:
        """Return information about the current user."""
        user: str
        if request.user.is_authenticated:
            user = request.user.username
        else:
            user = "<anonymous>"
        return Response(
            {
                "user": user,
                "auth": bool(request.auth),
                "worker_token": bool(context.worker_token),
            }
        )
