import functools
import logging

from pyramid.authorization import Authenticated
from pyramid.interfaces import IAuthorizationPolicy
from pyramid.settings import aslist
from zope.interface import implementer

from kinto.core import utils
from kinto.core.storage import exceptions as storage_exceptions


logger = logging.getLogger(__name__)

# When permission is set to "private", only the current user is allowed.
PRIVATE = "private"

# A permission is called "dynamic" when it's computed at request time.
DYNAMIC = "dynamic"


def groupfinder(userid, request):
    """Fetch principals from permission backend for the specified `userid`.

    This is plugged by default using the ``multiauth.groupfinder`` setting.
    """
    backend = getattr(request.registry, "permission", None)
    # Permission backend not configured. Ignore.
    if not backend:
        return []

    # Safety check when Kinto-Core is used without pyramid_multiauth.
    if request.prefixed_userid:
        userid = request.prefixed_userid

    # Query the permission backend only once per request (e.g. batch).
    reify_key = userid + "_principals"
    if reify_key not in request.bound_data:
        principals = backend.get_user_principals(userid)
        request.bound_data[reify_key] = principals

    return request.bound_data[reify_key]


@implementer(IAuthorizationPolicy)
class AuthorizationPolicy:
    """Default authorization class, that leverages the permission backend
    for shareable resources.
    """

    get_bound_permissions = None
    """Callable that takes an object id and a permission and returns
    a list of tuples (<object id>, <permission>). Useful when objects
    permission depend on others."""

    def permits(self, context, principals, permission):
        if permission == PRIVATE:
            # When using the private permission, we bypass the permissions
            # backend, and simply authorize if authenticated.
            return Authenticated in principals

        principals = context.get_prefixed_principals()

        create_permission = f"{context.resource_name}:create"

        permission = context.required_permission
        if permission == "create":
            permission = create_permission

        object_id = context.permission_object_id
        bound_perms = self._get_bound_permissions(object_id, permission)

        allowed = context.check_permission(principals, bound_perms)

        # Here we consider that parent URI is one path level above.
        parent_uri = "/".join(object_id.split("/")[:-1]) if object_id else None

        # If not allowed to delete/patch, and target object is missing, and
        # allowed to read the parent, then view is permitted (will raise 404
        # later anyway). See Kinto/kinto#918
        is_object_unknown = not context.on_plural_endpoint and context.current_object is None
        if context.required_permission == "write" and is_object_unknown:
            bound_perms = self._get_bound_permissions(parent_uri, "read")
            allowed = context.check_permission(principals, bound_perms)

        # If not allowed on this plural endpoint, but some objects are shared with
        # the current user, then authorize.
        # The :class:`kinto.core.resource.Resource` class will take care of the filtering.
        is_list_operation = (
            context.on_plural_endpoint
            and not permission.endswith("create")
            and context.current_object is None
        )
        if not allowed and is_list_operation:
            allowed = bool(
                context.fetch_shared_objects(permission, principals, self.get_bound_permissions)
            )
            if not allowed:
                # If allowed to create this kind of object on parent,
                # then allow to obtain the list.
                if len(bound_perms) > 0:
                    bound_perms = [(parent_uri, create_permission)]
                else:
                    bound_perms = [("", "create")]  # Root object.
                allowed = context.check_permission(principals, bound_perms)

        if not allowed:
            logger.info(
                "Permission %r on %r not granted to %r.",
                permission,
                object_id,
                principals[0],
                extra=dict(userid=principals[0], uri=object_id, perm=permission),
            )

        return allowed

    def _get_bound_permissions(self, object_id, permission):
        if self.get_bound_permissions is None:
            # Permission to 'write' gives permission to 'read'.
            bound = [(object_id, permission)]
            if permission == "read":
                bound += [(object_id, "write")]
            return bound
        return self.get_bound_permissions(object_id, permission)

    def principals_allowed_by_permission(self, context, permission):
        raise NotImplementedError()  # PRAGMA NOCOVER


class RouteFactory:
    resource_name = None
    on_plural_endpoint = False
    required_permission = None
    permission_object_id = None
    current_object = None
    shared_ids = None

    method_permissions = {
        "head": "read",
        "get": "read",
        "post": "create",
        "delete": "write",
        "patch": "write",
    }

    def __init__(self, request):
        # Store some shortcuts.
        permission = request.registry.permission
        self._check_permission = permission.check_permission
        self._get_accessible_objects = permission.get_accessible_objects

        self.get_prefixed_principals = functools.partial(utils.prefixed_principals, request)

        # Store current resource and required permission.
        service = utils.current_service(request)
        is_on_resource = (
            service is not None and hasattr(service, "viewset") and hasattr(service, "resource")
        )
        self._resource = None
        if is_on_resource:
            self.resource_name = request.current_resource_name
            self.on_plural_endpoint = getattr(service, "type", None) == "plural"

            # Check if this request targets an individual object.
            # Its existence will affect permissions checking (cf `_find_required_permission()`).
            # There are cases where the permission is not directly related to the HTTP method,
            # For example:
            # - with POST on plural endpoint, with an id supplied
            # - with PUT on an object, which can either be creation or update
            is_write_on_object = not self.on_plural_endpoint and request.method.lower() in (
                "put",
                "delete",
                "patch",
            )
            is_post_on_plural = self.on_plural_endpoint and request.method.lower() == "post"
            if is_write_on_object or is_post_on_plural:
                # We instantiate the resource to determine the object targeted by the request.
                self._resource = resource = service.resource(request=request, context=self)
                if resource.object_id is not None:  # Skip POST on plural without id.
                    try:
                        # Save a reference, to avoid refetching from storage in resource.
                        self.current_object = resource.model.get_object(resource.object_id)
                    except storage_exceptions.ObjectNotFoundError:
                        pass

            self.permission_object_id, self.required_permission = self._find_required_permission(
                request, service
            )

            # To obtain shared objects on a plural endpoint, use a match:
            self._object_id_match = self.get_permission_object_id(request, "*")

        self._settings = request.registry.settings

    def check_permission(self, principals, bound_perms):
        """Read allowed principals from settings, if not any, query the permission
        backend to check if view is allowed.
        """
        if not bound_perms:
            bound_perms = [(self.resource_name, self.required_permission)]
        for _, permission in bound_perms:
            # With Kinto inheritance tree, we can have: `permission = "record:create"`
            if self.resource_name and permission.startswith(self.resource_name):
                setting = f"{permission.replace(':', '_')}_principals"
            else:
                setting = f"{self.resource_name}_{permission}_principals"
            allowed_principals = aslist(self._settings.get(setting, ""))
            if allowed_principals:
                if bool(set(allowed_principals) & set(principals)):
                    return True
        return self._check_permission(principals, bound_perms)

    def fetch_shared_objects(self, perm, principals, get_bound_permissions):
        """Fetch objects that are readable or writable for the current
        principals.

        See :meth:`kinto.core.authorization.AuthorizationPolicy.permits`

        If no object is shared, it returns None.

        .. warning::
            This sets the ``shared_ids`` attribute to the context with the
            return value. The attribute is then read by
            :class:`kinto.core.resource.Resource`
        """
        if get_bound_permissions:
            bound_perms = get_bound_permissions(self._object_id_match, perm)
        else:
            bound_perms = [(self._object_id_match, perm)]
        by_obj_id = self._get_accessible_objects(principals, bound_perms, with_children=False)
        ids = by_obj_id.keys()
        # Store for later use in ``Resource``.
        self.shared_ids = [self._extract_object_id(id_) for id_ in ids]
        return self.shared_ids

    def get_permission_object_id(self, request, object_id=None):
        """Returns the permission object id for the current request.
        In the nominal case, it is just the current URI without version prefix.
        For plural endpoint, it is the related object URI using the specified
        `object_id`.

        See :meth:`kinto.core.resource.model.SharableModel` and
        :meth:`kinto.core.authorization.RouteFactory.__init__`
        """
        object_uri = utils.strip_uri_prefix(request.path)

        if self.on_plural_endpoint and object_id is not None:
            # With the current request on a plural endpoint, the object URI must
            # be found out by inspecting the "plural" service and its sibling
            # "object" service. (see `register_resource()`)
            matchdict = {**request.matchdict, "id": object_id}
            try:
                object_uri = utils.instance_uri(request, self.resource_name, **matchdict)
                object_uri = object_uri.replace("%2A", "*")
            except KeyError:
                # Maybe the resource has no single object endpoint.
                # We consider that object URIs in permissions backend will
                # be stored naively:
                object_uri = f"{object_uri}/{object_id}"

        return object_uri

    def _extract_object_id(self, object_uri):
        # XXX: Rewrite using kinto.core.utils.view_lookup() and matchdict['id']
        return object_uri.split("/")[-1]

    def _find_required_permission(self, request, service):
        """Find out what is the permission object id and the required
        permission.

        .. note::
            This method saves an attribute ``self.current_object`` used
            in :class:`kinto.core.resource.Resource`.
        """
        # By default, it's a URI a and permission associated to the method.
        permission_object_id = self.get_permission_object_id(request)
        method = request.method.lower()
        required_permission = self.method_permissions.get(method)

        # For create permission, the object id is the plural endpoint.
        plural_path = str(service.plural_path)
        plural_path = plural_path.format_map(request.matchdict)

        # In the case of a "PUT", check if the targetted object already
        # exists, return "write" if it does, "create" otherwise.
        if request.method.lower() == "put":
            if self.current_object is None:
                # The object does not exist, the permission to create on
                # the related plural endpoint is required.
                permission_object_id = plural_path
                required_permission = "create"
            else:
                # For safe creations, the user needs a create permission.
                # See Kinto/kinto#792
                if request.headers.get("If-None-Match") == "*":
                    permission_object_id = plural_path
                    required_permission = "create"
                else:
                    required_permission = "write"

        # In the case of a "POST" on a plural endpoint, if an "id" was
        # specified, then the object is returned. The required permission
        # is thus "read" on this object.
        if request.method.lower() == "post" and self.current_object is not None:
            permission_object_id = self.get_permission_object_id(
                request, object_id=self._resource.object_id
            )
            required_permission = "read"

        return (permission_object_id, required_permission)
