# 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.

"""Context information needed to check permissions."""

from dataclasses import dataclass, field, replace
from typing import Self, TYPE_CHECKING, overload, override

from django.contrib.auth.models import AnonymousUser
from django.db.models import Q, QuerySet

if TYPE_CHECKING:
    from debusine.db.models import (
        CollectionTokenGrant,
        Group,
        Token,
        User,
        WorkRequest,
    )
    from debusine.db.models.auth import GroupQuerySet

#: Type alias for the user variable used by permission predicates
type PermissionUser = "User | AnonymousUser"


@dataclass(frozen=True)
class PermissionContext:
    """Context information passed to permission predicates."""

    @staticmethod
    def _extra_groups_default_factory() -> "GroupQuerySet[Group]":
        from debusine.db.models import Group

        return Group.objects.none()

    @staticmethod
    def _extra_collection_grants_default_factory() -> (
        "QuerySet[CollectionTokenGrant]"
    ):
        from debusine.db.models import CollectionTokenGrant

        return CollectionTokenGrant.objects.none()

    #: The current user.
    user: PermissionUser
    #: Extra groups used for additional permissions.
    extra_groups: "GroupQuerySet[Group]" = field(
        default_factory=_extra_groups_default_factory,
        # Ignore extra_groups for comparisons, since it is derived from
        # work_request.
        compare=False,
    )
    #: Extra roles on resources granted to specific authorization contexts.
    extra_collection_grants: "QuerySet[CollectionTokenGrant]" = field(
        default_factory=_extra_collection_grants_default_factory,
        # Ignore extra_collection_grants for comparisons, since it is
        # derived from the token.
        compare=False,
    )
    #: True if permission checks should always succeed.
    #: This is used to skip permission checks in situations like management
    #: commands or test fixture setup.
    permission_checks_disabled: bool = False
    #: The current user token, if used.
    user_token: "Token | None" = None
    #: The current external user token, if used.
    external_user_token: "Token | None" = None
    #: The current worker token, if used.
    worker_token: "Token | None" = None
    #: The current work request token, if used.
    work_request_token: "Token | None" = None
    #: The current worker activation token, if used
    worker_activation_token: "Token | None" = None
    #: The current work request, if used.
    work_request: "WorkRequest | None" = None

    @override
    def __str__(self) -> str:
        res = str(self.user)
        if self.user_token:
            res += "[user token]"
        if self.external_user_token:
            res += "[external user token]"
        if self.worker_token:
            res += "[worker token]"
        if self.worker_activation_token:
            res += "[worker-activation token]"
        if self.work_request_token:
            res += "[work-request token]"
        if self.work_request:
            res += f"[work request {self.work_request}]"
        extra_groups = tuple(self.extra_groups.order_by("pk"))
        if extra_groups:
            res += f"[extra groups {', '.join(str(g) for g in extra_groups)}]"
        return res

    @override
    def __repr__(self) -> str:
        return str(self)

    def with_permission_checks_disabled(self) -> Self:
        """Return a copy of this PermissionContext with checks disabled."""
        return replace(self, permission_checks_disabled=True)

    def groups(self) -> "GroupQuerySet[Group]":
        """Return a queryset selecting the active groups."""
        # Prevent circular import
        from debusine.db.models import Group

        if not self.user.is_authenticated:
            return Group.objects.none()

        return Group.objects.filter(
            Q(users=self.user) | Q(pk__in=self.extra_groups.values("pk"))
        )

    @property
    def explicit_grants_only(self) -> bool:
        """Return whether this context's token requires explicit grants."""
        token = (
            self.user_token
            or self.external_user_token
            or self.work_request_token
        )
        if token is not None:
            return token.explicit_grants_only
        else:
            return False

    @property
    def is_non_permission_bearing_token(self) -> bool:
        """Return whether the token is one that cannot grant any permissions."""
        return (self.worker_token or self.worker_activation_token) is not None

    @classmethod
    def for_ui_anonymous(cls) -> Self:
        """Return a PermissionContext for an anonymous user in the UI."""
        return cls(user=AnonymousUser())

    @classmethod
    def for_ui_user(cls, user: "User") -> Self:
        """Return a PermissionContext for a user authenticating in the UI."""
        return cls(user=user)

    @classmethod
    def for_api_user(cls, token: "Token") -> Self:
        """Return a PermissionContext for a user authenticating in the API."""
        # Prevent circular import
        from debusine.db.models import Token

        assert token.token_type == Token.Types.USER
        assert token.enabled
        assert token.user is not None
        return cls(user_token=token, user=token.user)

    @classmethod
    def for_api_external_user(cls, token: "Token") -> Self:
        """
        Return a PermissionContext for an external user via the API.

        External users have a username, but do not correspond to Debusine
        users.  They largely act as the anonymous user, but with specific
        additional grants.
        """
        from debusine.db.models import Token

        assert token.token_type == Token.Types.EXTERNAL_USER
        assert token.enabled
        return cls(
            user=AnonymousUser(),
            external_user_token=token,
            extra_collection_grants=token.collection_grants.all(),
        )

    @classmethod
    def for_worker(cls, token: "Token") -> Self:
        """Return a PermissionContext for a worker connecting to Debusine."""
        from debusine.db.models import Token

        assert token.token_type == Token.Types.WORKER
        assert token.enabled
        assert token.user is None  # enforced by database constraint
        return cls(user=AnonymousUser(), worker_token=token)

    @overload
    @classmethod
    def for_work_request(cls, *, token: "Token") -> Self: ...

    @overload
    @classmethod
    def for_work_request(cls, *, work_request: "WorkRequest") -> Self: ...

    @classmethod
    def for_work_request(
        cls,
        *,
        token: "Token | None" = None,
        work_request: "WorkRequest | None" = None,
    ) -> Self:
        """Return a PermissionContext for a worker processing a work request."""
        from debusine.db.models import Token

        if token is not None:
            assert work_request is None
            assert token.token_type == Token.Types.WORK_REQUEST
            assert token.work_request is not None
            assert token.enabled
            return cls(
                user=token.work_request.created_by,
                extra_groups=token.work_request.get_extra_groups(),
                work_request_token=token,
                work_request=token.work_request,
            )
        elif work_request is not None:
            return cls(
                user=work_request.created_by,
                extra_groups=work_request.get_extra_groups(),
                work_request=work_request,
            )
        else:
            raise AssertionError("neither token nor work_request passed")

    @classmethod
    def for_worker_activation(cls, token: "Token") -> Self:
        """Return a PermissionContext for worker activation."""
        from debusine.db.models import Token

        assert token.token_type == Token.Types.WORKER_ACTIVATION
        assert token.enabled
        return cls(user=AnonymousUser(), worker_activation_token=token)

    @classmethod
    def for_maintenance(cls) -> Self:
        """Return a PermissionContext for maintenance commands."""
        from debusine.db.models.auth import system_user

        return cls(user=system_user())
