from functools import wraps
from urllib.parse import urlparse

from django.conf import settings
from django.contrib.auth.backends import BaseBackend
from django.contrib.auth.mixins import (
    PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.auth.models import Permission
from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, resolve_url

DEFAULT_403 = getattr(settings, 'DJEM_DEFAULT_403', False)


def _get_user_log_verbosity():
    
    return getattr(settings, 'DJEM_PERM_LOG_VERBOSITY', 0)


class ObjectPermissionsBackend(BaseBackend):
    
    def _get_model_permission(self, perm, user_obj):
        
        verbosity = _get_user_log_verbosity()
        if not verbosity:
            access = user_obj.has_perm(perm)
        else:
            access = user_obj.logged_has_perm(perm)
            
            # Add the log from the model-level check to the log for the
            # object-level check, replacing the "result" line
            log = user_obj.get_last_log(raw=True)
            log.pop()
            log.append('Model-level Result: {}\n'.format('Granted' if access else 'Denied'))
            user_obj.log(*log, tag='auto')
        
        return access
    
    def _get_object_permission(self, perm, user_obj, obj, from_name):
        """
        Test if a user has a permission on a specific model object.
        ``from_name`` can be either "user" or "group", to determine permissions
        using the user object itself or the groups it belongs to, respectively.
        """
        
        if not user_obj.is_active:  # pragma: no cover
            # An inactive user won't normally get this far as they would not
            # pass the model-level permissions check
            return False
        
        try:
            perm_cache = user_obj._olp_cache
        except AttributeError:
            # OLP cache dictionary will not exist by default if not using
            # OLPMixin and no permissions have yet been checked on this user
            # object
            perm_cache = user_obj._olp_cache = {}
        
        perm_cache_name = f'{from_name}-{perm}-{obj.pk}'
        
        if perm_cache_name not in perm_cache:
            perm_codename = perm.split('.')[-1]
            access_fn_name = f'_{from_name}_can_{perm_codename}'
            access_fn = getattr(obj, access_fn_name, None)
            
            if not access_fn:
                # No function defined on obj to determine access - assume
                # access should be granted if no explicit object-level logic
                # exists to determine otherwise
                access = None
            else:
                try:
                    if from_name == 'user':
                        access = access_fn(user_obj)
                    else:
                        access = access_fn(user_obj.groups.all())
                except PermissionDenied:
                    access = False
            
            perm_cache[perm_cache_name] = access
        
        return perm_cache[perm_cache_name]
    
    def _get_object_permissions(self, user_obj, obj, from_name=None):
        """
        Return a set of the permissions a user has on a specific model object.
        ``from_name`` can be either "user" or "group", to determine permissions
        using the user object itself or the groups it belongs to, respectively.
        It can also be None to determine the users permissions from both sources.
        """
        
        if not obj or not user_obj.is_active or user_obj.is_anonymous:
            return set()
        
        perms_for_model = Permission.objects.filter(
            content_type__app_label=obj._meta.app_label,
            content_type__model=obj._meta.model_name,
        ).values_list('content_type__app_label', 'codename')
        
        perms_for_model = [f'{app}.{name}' for app, name in perms_for_model]
        
        if user_obj.is_superuser and not getattr(settings, 'DJEM_UNIVERSAL_OLP', False):
            # Superusers get all permissions, regardless of obj or from_name,
            # unless using "universal" OLP, in which case they are subject to
            # the same OLP logic as regular users
            perms = set(perms_for_model)
        else:
            # If using any level of automated logging for permissions, create a
            # temporary log to act as the target for any log entries (either
            # automatic entries appended by this backend or any manual entries
            # that may be present in object-level access methods). The usual
            # automatic log started as part of OLPMixin.has_perm() will not
            # have been created, so this acts as a replacement.
            log_verbosity = _get_user_log_verbosity()
            if log_verbosity:
                user_obj.start_log(f'temp-{obj.pk}')
            
            perms = set()
            for perm in perms_for_model:
                if not self._get_model_permission(perm, user_obj):
                    continue
                
                user_access = None
                group_access = None
                
                # Check user first, unless only checking for group
                if from_name != 'group':
                    user_access = self._get_object_permission(perm, user_obj, obj, 'user')
                
                # Check group if user didn't grant the permission, unless only
                # checking for user
                if not user_access and from_name != 'user':
                    group_access = self._get_object_permission(perm, user_obj, obj, 'group')
                
                # The permission is granted if either of the user or group
                # checks grant it, or if neither of them have a defined
                # object-level access method
                if user_access or group_access or (user_access is None and group_access is None):
                    perms.add(perm)
            
            # Remove the temporary log, if one was created
            if log_verbosity:
                user_obj.discard_log()
        
        return perms
    
    def get_user_permissions(self, user_obj, obj=None):
        
        return self._get_object_permissions(user_obj, obj, 'user')
    
    def get_group_permissions(self, user_obj, obj=None):
        
        return self._get_object_permissions(user_obj, obj, 'group')
    
    def get_all_permissions(self, user_obj, obj=None):
        
        return self._get_object_permissions(user_obj, obj)
    
    def has_perm(self, user_obj, perm, obj=None):
        
        if not obj:
            return False  # not dealing with non-object permissions
        
        if not self._get_model_permission(perm, user_obj):
            return False
        
        user_access = self._get_object_permission(perm, user_obj, obj, 'user')
        group_access = None
        
        # Check group if user didn't grant the permission
        if not user_access:
            group_access = self._get_object_permission(perm, user_obj, obj, 'group')
        
        # The permission is granted if either of the user or group
        # checks grant it, or if neither of them have a defined
        # object-level access method
        return user_access or group_access or (user_access is None and group_access is None)


def _check_perms(perms, user, view_kwargs):
    
    for perm in perms:
        if isinstance(perm, str):
            perm_name = perm
            obj = None
        else:
            perm_name, obj_arg = perm  # expand two-tuple
            obj_pk = view_kwargs[obj_arg]
            
            # Get the model this permission belongs to
            try:
                perm_app, perm_code = perm_name.split('.')
                perm_obj = Permission.objects.get(
                    content_type__app_label=perm_app,
                    codename=perm_code
                )
            except (ValueError, Permission.DoesNotExist) as e:
                # Treat malformed (missing a '.') or non-existent
                # permission names as permission denied
                raise PermissionDenied from e
            
            model = perm_obj.content_type.model_class()
            
            # Get the object instance using the inferred model and the
            # primary key passed to the view
            obj = get_object_or_404(model, pk=obj_pk)
            
            # Swap out the primary key with the instance itself in the view
            # kwargs, so the view doesn't have to query for it again
            view_kwargs[obj_arg] = obj
        
        if not user.has_perm(perm_name, obj):
            raise PermissionDenied


def permission_required(*perms, **kwargs):
    """
    Replacement for Django's ``permission_required`` decorator, providing
    support for object-level permissions. Instead of accepting either a string
    or an iterable of strings naming the permission/s to check, this version
    accepts multiple positional arguments, one for each permission to check.
    These arguments can be either strings or two-tuples. If two-tuples, the
    items should be:
      - a string naming the permission to check (in the <app label>.<permission code>
        format)
      - a string naming the keyword argument of the view that contains the
        primary key of the object to check the permission against
    
    Behaviour of the ``login_url`` and ``raise_exception`` keyword arguments is
    as per the original, except that the default value for ``raise_exception``
    can be specified with the ``DJEM_DEFAULT_403`` setting.
    """
    
    login_url = kwargs.pop('login_url', None)
    raise_exception = kwargs.pop('raise_exception', DEFAULT_403)
    
    def decorator(view_func):
        
        @wraps(view_func)
        def _wrapped_view(request, *args, **kwargs):
            
            # First, check if the user has the permission (even anon users)
            try:
                _check_perms(perms, request.user, kwargs)
            except PermissionDenied:
                # In case the 403 handler should be called, raise the exception
                if raise_exception:
                    raise
            else:
                return view_func(request, *args, **kwargs)
            
            # As the last resort, show the login form
            path = request.build_absolute_uri()
            resolved_login_url = resolve_url(login_url or settings.LOGIN_URL)
            
            # If the login url is the same scheme and net location then just
            # use the path as the "next" url.
            login_scheme, login_netloc = urlparse(resolved_login_url)[:2]
            current_scheme, current_netloc = urlparse(path)[:2]
            
            if ((not login_scheme or login_scheme == current_scheme)
                    and (not login_netloc or login_netloc == current_netloc)):
                path = request.get_full_path()
            
            return redirect_to_login(path, resolved_login_url)
        
        return _wrapped_view
    
    return decorator


class PermissionRequiredMixin(DjangoPermissionRequiredMixin):
    """
    CBV mixin which verifies that the current user has all specified
    permissions, on the specified object where applicable.
    """
    
    raise_exception = DEFAULT_403
    
    def has_permission(self, view_kwargs):
        
        perms = self.get_permission_required()
        
        try:
            _check_perms(perms, self.request.user, view_kwargs)
        except PermissionDenied:
            return False
        else:
            return True
    
    # Overridden to pass kwargs to has_permission() and skip the immediate
    # parent's dispatch() when calling the super method (because it attempts
    # to call has_permission without the kwargs).
    def dispatch(self, request, *args, **kwargs):
        
        if not self.has_permission(kwargs):
            return self.handle_no_permission()
        
        # Skip DjangoPermissionRequiredMixin.dispatch() and call *its* parent directly
        return super(DjangoPermissionRequiredMixin, self).dispatch(request, *args, **kwargs)
