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

"""Basic permission check infrastructure."""

import enum
import functools
from collections.abc import Callable, Collection
from typing import (
    Any,
    Optional,
    Protocol,
    TYPE_CHECKING,
    TypeAlias,
    TypeVar,
    Union,
    overload,
    runtime_checkable,
)

from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import PermissionDenied
from django.db.models import Model, Q, QuerySet, TextChoices

from debusine.db.context import ContextConsistencyError, context

if TYPE_CHECKING:
    from debusine.db.models import Scope, User, Workspace


class Roles(TextChoices):
    """TextChoices used as roles."""


class PartialCheckResult(enum.StrEnum):
    """
    Result value enum for a partial check predicate.

    This is used by composable check methods, which can either report not
    having enough information to decide, or take an allow/deny decision.
    """

    ALLOW = "allow"
    DENY = "deny"
    PASS = "pass"


#: Type alias for the user variable used by permission predicates
PermissionUser: TypeAlias = Union["User", AnonymousUser, None]

M = TypeVar("M", bound=Model)
QS = TypeVar("QS", bound=QuerySet[Any, Any])
R = TypeVar("R", bound=Roles)


@runtime_checkable
class PermissionCheckPredicate(Protocol[M]):
    """Interface of a permission predicate on a resource."""

    __self__: M

    __func__: Callable[[M, "PermissionUser"], bool]

    def __call__(self, user: "PermissionUser") -> bool:
        """Test the predicate."""


def resolve_role(roles_class: type[R], role: R | str) -> R:
    """Convert a role in various forms into the right enum."""
    if isinstance(role, Roles):
        if not isinstance(role, roles_class):
            raise TypeError(f"{role!r} cannot be converted to {roles_class!r}")
        return role
    # Roles are TextChoices which are instances of str, and therefore also of
    # Collection. This test intends to catch all role names and Roles instances
    if isinstance(role, str):
        return roles_class(role)
    raise TypeError(f"{role!r} cannot be converted to {roles_class!r}")


@overload
def resolve_roles_list(
    roles_class: None, roles: Roles | str | Collection[Roles | str]
) -> list[Roles]: ...


@overload
def resolve_roles_list(
    roles_class: type[R], roles: R | str | Collection[R | str]
) -> list[R]: ...


def resolve_roles_list(
    roles_class: type[R] | None, roles: R | str | Collection[R | str]
) -> list[R]:
    """Convert roles into a list of the right enum."""
    # Roles are TextChoices which are instances of str, and therefore also of
    # Collection. This test intends to catch all roles that are not actual
    # collections of roles
    if roles_class is None:
        return []
    elif isinstance(roles, str):
        return [resolve_role(roles_class, roles)]
    return [resolve_role(roles_class, r) for r in roles]


def ROLES(user: "User", /, *roles: "Roles", member: str | None = None) -> Q:
    """
    Select elements for which user has at least one of the given roles.

    :param member: if provided, the check is done on the named member resource
    """
    if member:
        return Q(
            **{
                f"{member}__roles__group__users": user,
                f"{member}__roles__role__in": roles,
            }
        )
    else:
        return Q(roles__group__users=user, roles__role__in=roles)


P: TypeAlias = Callable[[M, PermissionUser], bool]


def permission_check(
    msg: str,
) -> Callable[[P[M]], P[M]]:
    """
    Implement common elements of permission checking predicates.

    Predicates should normally also check permissions on any containing
    resources, relying on query caching as needed for performance.  This
    provides some defence in depth against omitted checks.
    """

    def wrap(f: P[M]) -> P[M]:
        @functools.wraps(f)
        def wrapper(self: M, user: PermissionUser) -> bool:
            if context.permission_checks_disabled:
                return True

            # User has not been set in the context: context.user is passed, but
            # it contains None
            if user is None:
                if context.worker_token is None:
                    raise ContextConsistencyError("user was not set in context")
                else:
                    user = AnonymousUser()

            return f(self, user)

        setattr(wrapper, "error_template", msg)
        return wrapper

    return wrap


def permission_filter(
    f: Callable[[QS, PermissionUser], QS],
) -> Callable[[QS, PermissionUser], QS]:
    """
    Implement common elements of permission filtering predicates.

    Predicates should normally also check permissions on any containing
    resources, relying on query caching as needed for performance.  This
    provides some defence in depth against omitted checks.
    """

    @functools.wraps(f)
    def wrapper(self: QS, user: PermissionUser) -> QS:
        if context.permission_checks_disabled:
            return self

        # User has not been set in the context: context.user is passed, but it
        # contains None
        if user is None:
            if context.worker_token is None:
                raise ContextConsistencyError("user was not set in context")
            else:
                user = AnonymousUser()

        return f(self, user)

    return wrapper


def format_permission_check_error(
    predicate: Callable[[PermissionUser], bool], user: PermissionUser
) -> str:
    """Format a permission check error message."""
    assert hasattr(predicate, "error_template")
    error_template = predicate.error_template
    assert isinstance(error_template, str)
    assert hasattr(predicate, "__self__")
    return error_template.format(resource=predicate.__self__, user=user)


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

    raise PermissionDenied(
        format_permission_check_error(predicate, context.user),
    )


def get_resource_scope(obj: M) -> Optional["Scope"]:
    """
    Get the Scope object for a resource.

    :returns: None if the resource does not have a scope
    :raises NotImplementedError: for resources not currently supported
    """
    from debusine.db.models import ArtifactRelation, Scope, User

    match obj:
        case ArtifactRelation():
            return obj.artifact.workspace.scope
        case Scope():
            return obj
        case User():
            return None
        case _:
            if hasattr(obj, "scope"):
                assert isinstance(obj.scope, Scope)
                return obj.scope

            if hasattr(obj, "workspace"):
                assert isinstance(obj.workspace.scope, Scope)
                return obj.workspace.scope

            raise NotImplementedError(
                f"Cannot get scope for {obj.__class__.__name__} object"
            )


def get_resource_workspace(obj: M) -> Optional["Workspace"]:
    """
    Get the Workspace object for a resource.

    :returns: None if the resource does not have a workspace
    :raises NotImplementedError: for resources not currently supported
    """
    from debusine.db.models import (
        ArtifactRelation,
        Group,
        Scope,
        User,
        Workspace,
    )

    match obj:
        case ArtifactRelation():
            return obj.artifact.workspace
        case Scope() | User() | Group():
            return None
        case Workspace():
            return obj
        case _:
            if hasattr(obj, "workspace"):
                assert isinstance(obj.workspace, Workspace)
                return obj.workspace

            raise NotImplementedError(
                f"Cannot get workspace for {obj.__class__.__name__} object"
            )
