"""
General scope definitions and utilities

Scope functions generally return _immutable_ collections,
such as `frozenset` to avoid mutating cached values.
If needed, mutable copies can be made, e.g. `set(frozen_scopes)`

Scope variable nomenclature
---------------------------
scopes or 'raw' scopes: collection of scopes that may contain abbreviations (e.g., in role definition)
expanded scopes: set of expanded scopes without abbreviations (i.e., resolved metascopes, filters, and subscopes)
parsed scopes: dictionary format of expanded scopes (`read:users!user=name` -> `{'read:users': {user: [name]}`)
intersection : set of expanded scopes as intersection of 2 expanded scope sets
identify scopes: set of expanded scopes needed for identify (whoami) endpoints
reduced scopes: expanded scopes that have been reduced
"""
import functools
import inspect
import re
import warnings
from enum import Enum
from functools import lru_cache
from itertools import chain
from textwrap import indent

import sqlalchemy as sa
from tornado import web
from tornado.log import app_log

from . import orm, roles
from ._memoize import DoNotCache, FrozenDict, lru_cache_key

"""when modifying the scope definitions, make sure that `docs/source/rbac/generate-scope-table.py` is run
   so that changes are reflected in the documentation and REST API description."""
scope_definitions = {
    '(no_scope)': {'description': 'Identify the owner of the requesting entity.'},
    'self': {
        'description': 'Your own resources',
        'doc_description': 'The user’s own resources _(metascope for users, resolves to (no_scope) for services)_',
    },
    'inherit': {
        'description': 'Anything you have access to',
        'doc_description': 'Everything that the token-owning entity can access _(metascope for tokens)_',
    },
    'admin-ui': {
        'description': 'Access the admin page.',
        'doc_description': 'Access the admin page. Permission to take actions via the admin page granted separately.',
    },
    'admin:users': {
        'description': 'Read, write, create and delete users and their authentication state, not including their servers or tokens.',
        'subscopes': ['admin:auth_state', 'users', 'read:roles:users', 'delete:users'],
    },
    'admin:auth_state': {'description': 'Read a user’s authentication state.'},
    'users': {
        'description': 'Read and write permissions to user models (excluding servers, tokens and authentication state).',
        'subscopes': ['read:users', 'list:users', 'users:activity'],
    },
    'delete:users': {
        'description': "Delete users.",
    },
    'list:users': {
        'description': 'List users, including at least their names.',
        'subscopes': ['read:users:name'],
    },
    'read:users': {
        'description': 'Read user models (excluding including servers, tokens and authentication state).',
        'subscopes': [
            'read:users:name',
            'read:users:groups',
            'read:users:activity',
        ],
    },
    'read:users:name': {'description': 'Read names of users.'},
    'read:users:groups': {'description': 'Read users’ group membership.'},
    'read:users:activity': {'description': 'Read time of last user activity.'},
    'read:roles': {
        'description': 'Read role assignments.',
        'subscopes': ['read:roles:users', 'read:roles:services', 'read:roles:groups'],
    },
    'read:roles:users': {'description': 'Read user role assignments.'},
    'read:roles:services': {'description': 'Read service role assignments.'},
    'read:roles:groups': {'description': 'Read group role assignments.'},
    'users:activity': {
        'description': 'Update time of last user activity.',
        'subscopes': ['read:users:activity'],
    },
    'admin:servers': {
        'description': 'Read, start, stop, create and delete user servers and their state.',
        'subscopes': ['admin:server_state', 'servers'],
    },
    'admin:server_state': {'description': 'Read and write users’ server state.'},
    'servers': {
        'description': 'Start and stop user servers.',
        'subscopes': ['read:servers', 'delete:servers'],
    },
    'read:servers': {
        'description': 'Read users’ names and their server models (excluding the server state).',
        'subscopes': ['read:users:name'],
    },
    'delete:servers': {'description': "Stop and delete users' servers."},
    'tokens': {
        'description': 'Read, write, create and delete user tokens.',
        'subscopes': ['read:tokens'],
    },
    'read:tokens': {'description': 'Read user tokens.'},
    'admin:groups': {
        'description': 'Read and write group information, create and delete groups.',
        'subscopes': ['groups', 'read:roles:groups', 'delete:groups'],
    },
    'groups': {
        'description': 'Read and write group information, including adding/removing users to/from groups.',
        'subscopes': ['read:groups', 'list:groups'],
    },
    'list:groups': {
        'description': 'List groups, including at least their names.',
        'subscopes': ['read:groups:name'],
    },
    'read:groups': {
        'description': 'Read group models.',
        'subscopes': ['read:groups:name'],
    },
    'read:groups:name': {'description': 'Read group names.'},
    'delete:groups': {
        'description': "Delete groups.",
    },
    'list:services': {
        'description': 'List services, including at least their names.',
        'subscopes': ['read:services:name'],
    },
    'read:services': {
        'description': 'Read service models.',
        'subscopes': ['read:services:name'],
    },
    'read:services:name': {'description': 'Read service names.'},
    'read:hub': {'description': 'Read detailed information about the Hub.'},
    'access:servers': {
        'description': 'Access user servers via API or browser.',
    },
    'access:services': {
        'description': 'Access services via API or browser.',
    },
    'proxy': {
        'description': 'Read information about the proxy’s routing table, sync the Hub with the proxy and notify the Hub about a new proxy.'
    },
    'shutdown': {'description': 'Shutdown the hub.'},
    'read:metrics': {
        'description': "Read prometheus metrics.",
    },
}


class Scope(Enum):
    ALL = True


def _intersection_cache_key(scopes_a, scopes_b, db=None):
    """Cache key function for scope intersections"""
    return (frozenset(scopes_a), frozenset(scopes_b))


@lru_cache_key(_intersection_cache_key)
def _intersect_expanded_scopes(scopes_a, scopes_b, db=None):
    """Intersect two sets of scopes by comparing their permissions

    Arguments:
      scopes_a, scopes_b: sets of expanded scopes
      db (optional): db connection for resolving group membership

    Returns:
      intersection: set of expanded scopes as intersection of the arguments

    If db is given, group membership will be accounted for in intersections,
    Otherwise, it can result in lower than intended permissions,
          (i.e. users!group=x & users!user=y will be empty, even if user y is in group x.)
    """
    empty_set = frozenset()
    scopes_a = frozenset(scopes_a)
    scopes_b = frozenset(scopes_b)

    # cached lookups for group membership of users and servers
    @lru_cache()
    def groups_for_user(username):
        """Get set of group names for a given username"""
        # if we need a group lookup, the result is not cacheable
        nonlocal needs_db
        needs_db = True
        user = db.query(orm.User).filter_by(name=username).first()
        if user is None:
            return empty_set
        else:
            return {group.name for group in user.groups}

    @lru_cache()
    def groups_for_server(server):
        """Get set of group names for a given server"""
        username, _, servername = server.partition("/")
        return groups_for_user(username)

    parsed_scopes_a = parse_scopes(scopes_a)
    parsed_scopes_b = parse_scopes(scopes_b)

    # track whether we need a db lookup (for groups)
    # because we can't cache the intersection if we do
    # if there are no group filters, this is cacheable
    needs_db = False

    common_bases = parsed_scopes_a.keys() & parsed_scopes_b.keys()

    common_filters = {}
    warned = False
    for base in common_bases:
        filters_a = parsed_scopes_a[base]
        filters_b = parsed_scopes_b[base]
        if filters_a == Scope.ALL:
            common_filters[base] = filters_b
        elif filters_b == Scope.ALL:
            common_filters[base] = filters_a
        else:
            common_entities = filters_a.keys() & filters_b.keys()
            all_entities = filters_a.keys() | filters_b.keys()

            # if we don't have a db session, we can't check group membership
            # warn *if* there are non-overlapping user= and group= filters that we can't check
            if (
                db is None
                and not warned
                and 'group' in all_entities
                and ('user' in all_entities or 'server' in all_entities)
            ):
                # this could resolve wrong if there's a user or server only on one side and a group only on the other
                # check both directions: A has group X not in B group list AND B has user Y not in A user list
                for a, b in [(filters_a, filters_b), (filters_b, filters_a)]:
                    for b_key in ('user', 'server'):
                        if (
                            not warned
                            and "group" in a
                            and b_key in b
                            and a["group"].difference(b.get("group", []))
                            and b[b_key].difference(a.get(b_key, []))
                        ):
                            warnings.warn(
                                f"{base}[!{b_key}={b[b_key]}, !group={a['group']}] combinations of filters present,"
                                " without db access. Intersection between not considered."
                                " May result in lower than intended permissions.",
                                UserWarning,
                            )
                            warned = True
                            needs_db = True

            common_filters[base] = {
                entity: filters_a[entity] & filters_b[entity]
                for entity in common_entities
            }

            # resolve hierarchies (group/user/server) in both directions
            common_servers = common_filters[base].get("server", set())
            common_users = common_filters[base].get("user", set())

            for a, b in [(filters_a, filters_b), (filters_b, filters_a)]:
                if 'server' in a and b.get('server') != a['server']:
                    # skip already-added servers (includes overlapping servers)
                    servers = a['server'].difference(common_servers)

                    # resolve user/server hierarchy
                    if servers and 'user' in b:
                        for server in servers:
                            username, _, servername = server.partition("/")
                            if username in b['user']:
                                common_servers.add(server)

                    # resolve group/server hierarchy if db available
                    servers = servers.difference(common_servers)
                    if db is not None and servers and 'group' in b:
                        needs_db = True
                        for server in servers:
                            server_groups = groups_for_server(server)
                            if server_groups & b['group']:
                                common_servers.add(server)

                # resolve group/user hierarchy if db available and user sets aren't identical
                if (
                    db is not None
                    and 'user' in a
                    and 'group' in b
                    and b.get('user') != a['user']
                ):
                    # skip already-added users (includes overlapping users)
                    users = a['user'].difference(common_users)
                    for username in users:
                        groups = groups_for_user(username)
                        if groups & b["group"]:
                            common_users.add(username)

            # add server filter if there wasn't one before
            if common_servers and "server" not in common_filters[base]:
                common_filters[base]["server"] = common_servers

            # add user filter if it's non-empty and there wasn't one before
            if common_users and "user" not in common_filters[base]:
                common_filters[base]["user"] = common_users

    intersection = unparse_scopes(common_filters)
    if needs_db:
        # return intersection, but don't cache it if it needed db lookups
        return DoNotCache(intersection)

    return intersection


def get_scopes_for(orm_object):
    """Find scopes for a given user or token from their roles and resolve permissions

    Arguments:
      orm_object: orm object or User wrapper

    Returns:
      expanded scopes (set) for the orm object
      or
      intersection (set) if orm_object == orm.APIToken
    """
    expanded_scopes = set()
    if orm_object is None:
        return expanded_scopes

    if not isinstance(orm_object, orm.Base):
        from .user import User

        if isinstance(orm_object, User):
            orm_object = orm_object.orm_user
        else:
            raise TypeError(
                f"Only allow orm objects or User wrappers, got {orm_object}"
            )

    owner = None
    if isinstance(orm_object, orm.APIToken):
        owner = orm_object.user or orm_object.service
        owner_roles = roles.get_roles_for(owner)
        owner_scopes = roles.roles_to_expanded_scopes(owner_roles, owner)

        token_scopes = set(orm_object.scopes)
        if 'inherit' in token_scopes:
            # token_scopes includes 'inherit',
            # so we know the intersection is exactly the owner's scopes
            # only thing we miss by short-circuiting here: warning about excluded extra scopes
            return owner_scopes

        token_scopes = set(
            expand_scopes(
                token_scopes,
                owner=owner,
                oauth_client=orm_object.oauth_client,
            )
        )

        if orm_object.client_id != "jupyterhub":
            # oauth tokens can be used to access the service issuing the token,
            # assuming the owner itself still has permission to do so
            token_scopes.update(access_scopes(orm_object.oauth_client))

        # reduce to collapse multiple filters on the same scope
        # to avoid spurious logs about discarded scopes
        token_scopes.update(identify_scopes(owner))
        token_scopes = reduce_scopes(token_scopes)

        intersection = _intersect_expanded_scopes(
            token_scopes,
            owner_scopes,
            db=sa.inspect(orm_object).session,
        )
        discarded_token_scopes = token_scopes - intersection

        # Not taking symmetric difference here because token owner can naturally have more scopes than token
        if discarded_token_scopes:
            app_log.warning(
                f"discarding scopes [{discarded_token_scopes}],"
                f" not present in roles of owner {owner}"
            )
            app_log.debug(
                "Owner %s has scopes: %s\nToken has scopes: %s",
                owner,
                owner_scopes,
                token_scopes,
            )
        expanded_scopes = intersection
        # always include identify scopes
        expanded_scopes
    else:
        expanded_scopes = roles.roles_to_expanded_scopes(
            roles.get_roles_for(orm_object),
            owner=orm_object,
        )
        if isinstance(orm_object, (orm.User, orm.Service)):
            owner = orm_object

    return expanded_scopes


@lru_cache()
def _expand_self_scope(username):
    """
    Users have a metascope 'self' that should be expanded to standard user privileges.
    At the moment that is a user-filtered version (optional read) access to
    users
    users:name
    users:groups
    users:activity
    tokens
    servers
    access:servers


    Arguments:
      username (str): user name

    Returns:
      expanded scopes (set): set of expanded scopes covering standard user privileges
    """
    scope_list = [
        'read:users',
        'read:users:name',
        'read:users:groups',
        'users:activity',
        'read:users:activity',
        'servers',
        'delete:servers',
        'read:servers',
        'tokens',
        'read:tokens',
        'access:servers',
    ]
    # return immutable frozenset because the result is cached
    return frozenset(f"{scope}!user={username}" for scope in scope_list)


@lru_cache(maxsize=65535)
def _expand_scope(scope):
    """Returns a scope and all all subscopes

    Arguments:
      scope (str): the scope to expand

    Returns:
      expanded scope (set): set of all scope's subscopes including the scope itself
    """

    # remove filter, save for later
    scope_name, sep, filter_ = scope.partition('!')

    # expand scope and subscopes
    expanded_scope_names = set()

    def _add_subscopes(scope_name):
        expanded_scope_names.add(scope_name)
        if scope_definitions[scope_name].get('subscopes'):
            for subscope in scope_definitions[scope_name].get('subscopes'):
                _add_subscopes(subscope)

    _add_subscopes(scope_name)

    # reapply !filter
    if filter_:
        expanded_scopes = {
            f"{scope_name}!{filter_}"
            for scope_name in expanded_scope_names
            # server scopes have some cross-resource subscopes
            # where the !server filter doesn't make sense,
            # e.g. read:servers -> read:users:name
            if not (filter_.startswith("server") and scope_name.startswith("read:user"))
        }
    else:
        expanded_scopes = expanded_scope_names

    # return immutable frozenset because the result is cached
    return frozenset(expanded_scopes)


def _expand_scopes_key(scopes, owner=None, oauth_client=None):
    """Cache key function for expand_scopes

    scopes is usually a mutable list or set,
    which can be hashed as a frozenset

    For the owner, we only care about what kind they are,
    and their name.
    """
    # freeze scopes for hash
    frozen_scopes = frozenset(scopes)
    if owner is None:
        owner_key = None
    else:
        # owner key is the type and name
        owner_key = (type(owner).__name__, owner.name)
    if oauth_client is None:
        oauth_client_key = None
    else:
        oauth_client_key = oauth_client.identifier
    return (frozen_scopes, owner_key, oauth_client_key)


@lru_cache_key(_expand_scopes_key)
def expand_scopes(scopes, owner=None, oauth_client=None):
    """Returns a set of fully expanded scopes for a collection of raw scopes

    Arguments:
      scopes (collection(str)): collection of raw scopes
      owner (obj, optional): orm.User or orm.Service as owner of orm.APIToken
          Used for expansion of metascopes such as `self`
          and owner-based filters such as `!user`
      oauth_client (obj, optional): orm.OAuthClient
          The issuing OAuth client of an API token.

    Returns:
      expanded scopes (set): set of all expanded scopes, with filters applied for the owner
    """
    expanded_scopes = set(chain.from_iterable(map(_expand_scope, scopes)))

    filter_replacements = {
        "user": None,
        "service": None,
        "server": None,
    }
    user_name = None
    if isinstance(owner, orm.User):
        user_name = owner.name
        filter_replacements["user"] = f"user={user_name}"
    elif isinstance(owner, orm.Service):
        filter_replacements["service"] = f"service={owner.name}"

    if oauth_client is not None:
        if oauth_client.service is not None:
            filter_replacements["service"] = f"service={oauth_client.service.name}"
        elif oauth_client.spawner is not None:
            spawner = oauth_client.spawner
            filter_replacements["server"] = f"server={spawner.user.name}/{spawner.name}"

    for scope in expanded_scopes.copy():
        base_scope, _, filter = scope.partition('!')
        if filter in filter_replacements:
            # translate !user into !user={username}
            # and !service into !service={servicename}
            # and !server into !server={username}/{servername}
            expanded_scopes.remove(scope)
            expanded_filter = filter_replacements[filter]
            if expanded_filter:
                # translate
                expanded_scopes.add(f'{base_scope}!{expanded_filter}')
            else:
                warnings.warn(
                    f"Not expanding !{filter} filter without target {filter} in {scope}",
                    stacklevel=3,
                )

    if 'self' in expanded_scopes:
        expanded_scopes.remove('self')
        if user_name:
            expanded_scopes |= _expand_self_scope(user_name)
        else:
            warnings.warn(
                f"Not expanding 'self' scope for owner {owner} which is not a User",
                stacklevel=3,
            )

    # reduce to discard overlapping scopes
    # return immutable frozenset because the result is cached
    return frozenset(reduce_scopes(expanded_scopes))


def _resolve_requested_scopes(requested_scopes, have_scopes, user, client, db):
    """Resolve requested scopes for an OAuth token

    Intersects requested scopes with user scopes.

    First, at the raw scope level,
    then if some scopes remain, intersect expanded scopes.

    Args:
        requested_scopes (set):
            raw scopes being requested.
        have_scopes (set):
            raw scopes currently held, against which requested_scopes will be checked.
        user (orm.User):
            user for whom the scopes will be issued
        client (orm.OAuthClient):
            oauth client which will own the token
        db:
            database session, required to resolve user|group intersections

    Returns:
        (allowed_scopes, disallowed_scopes):
            sets of allowed and disallowed scopes from the request
    """

    allowed_scopes = requested_scopes.intersection(have_scopes)
    disallowed_scopes = requested_scopes.difference(have_scopes)

    if not disallowed_scopes:
        # simple intersection worked, all scopes granted
        return (allowed_scopes, disallowed_scopes)

    # if we got here, some scopes were disallowed.
    # resolve fully expanded scopes to make sure scope intersections are properly allowed.
    expanded_allowed = expand_scopes(allowed_scopes, user, client)
    expanded_have = expand_scopes(have_scopes, user, client)
    # compute one at a time so we can keep the abbreviated scopes
    # if they are a subset of user scopes (e.g. requested !server, have !user)
    for scope in list(disallowed_scopes):
        expanded_disallowed = expand_scopes({scope}, user, client)
        # don't check already-allowed scopes
        expanded_disallowed -= expanded_allowed
        if expanded_disallowed:
            allowed_intersection = _intersect_expanded_scopes(
                expanded_disallowed, expanded_have, db=db
            )
        else:
            allowed_intersection = set()

        if allowed_intersection == expanded_disallowed:
            # full scope allowed (requested scope is subset of user scopes)
            allowed_scopes.add(scope)
            disallowed_scopes.remove(scope)
            expanded_allowed = expand_scopes(allowed_scopes, user, client)

        elif allowed_intersection:
            # some scopes get through, but not all,
            # allow the subset
            allowed_scopes |= allowed_intersection
            expanded_allowed = expand_scopes(allowed_scopes, user, client)
            # choice: report that the requested scope wasn't _fully_ granted (current behavior)
            # or report the exact (likely too detailed) set of not granted scopes (below)
            # disallowed_scopes.remove(scope)
            # disallowed_scopes |= expanded_disallowed.difference(allowed_intersection)
        else:
            # no new scopes granted, original check was right
            pass
    return (allowed_scopes, disallowed_scopes)


def _needs_scope_expansion(filter_, filter_value, sub_scope):
    """
    Check if there is a requirements to expand the `group` scope to individual `user` scopes.
    Assumptions:
    filter_ != Scope.ALL
    """
    if not (filter_ == 'user' and 'group' in sub_scope):
        return False
    if 'user' in sub_scope:
        return filter_value not in sub_scope['user']
    else:
        return True


def _check_user_in_expanded_scope(handler, user_name, scope_group_names):
    """Check if username is present in set of allowed groups"""
    user = handler.find_user(user_name)
    if user is None:
        raise web.HTTPError(404, "No access to resources or resources not found")
    group_names = {group.name for group in user.groups}
    return bool(set(scope_group_names) & group_names)


def _check_scope_access(api_handler, req_scope, **kwargs):
    """Check if scopes satisfy requirements
    Returns True for (potentially restricted) access, False for refused access
    """
    # Parse user name and server name together
    try:
        api_name = api_handler.request.path
    except AttributeError:
        api_name = type(api_handler).__name__
    if 'user' in kwargs and 'server' in kwargs:
        kwargs['server'] = "{}/{}".format(kwargs['user'], kwargs['server'])
    if req_scope not in api_handler.parsed_scopes:
        app_log.debug("No access to %s via %s", api_name, req_scope)
        return False
    if api_handler.parsed_scopes[req_scope] == Scope.ALL:
        app_log.debug("Unrestricted access to %s via %s", api_name, req_scope)
        return True
    # Apply filters
    sub_scope = api_handler.parsed_scopes[req_scope]
    if not kwargs:
        app_log.debug(
            "Client has restricted access to %s via %s. Internal filtering may apply",
            api_name,
            req_scope,
        )
        return True
    for filter_, filter_value in kwargs.items():
        if filter_ in sub_scope and filter_value in sub_scope[filter_]:
            app_log.debug("Argument-based access to %s via %s", api_name, req_scope)
            return True
        if _needs_scope_expansion(filter_, filter_value, sub_scope):
            group_names = sub_scope['group']
            if _check_user_in_expanded_scope(api_handler, filter_value, group_names):
                app_log.debug("Restricted client access supported with group expansion")
                return True
    app_log.debug(
        "Client access refused; filters do not match API endpoint %s request" % api_name
    )
    raise web.HTTPError(404, "No access to resources or resources not found")


def _check_scopes_exist(scopes, who_for=None):
    """Check if provided scopes exist

    Arguments:
      scopes (list): list of scopes to check

    Raises KeyError if scope does not exist
    """

    allowed_scopes = set(scope_definitions.keys())
    filter_prefixes = ('!user=', '!service=', '!group=', '!server=')
    exact_filters = {"!user", "!service", "!server"}

    if who_for:
        log_for = f"for {who_for}"
    else:
        log_for = ""

    for scope in scopes:
        scopename, _, filter_ = scope.partition('!')
        if scopename not in allowed_scopes:
            if scopename == "all":
                raise KeyError("Draft scope 'all' is now called 'inherit'")
            raise KeyError(f"Scope '{scope}' {log_for} does not exist")
        if filter_:
            full_filter = f"!{filter_}"
            if full_filter not in exact_filters and not full_filter.startswith(
                filter_prefixes
            ):
                raise KeyError(
                    f"Scope filter {filter_} '{full_filter}' in scope '{scope}' {log_for} does not exist"
                )


def _check_token_scopes(scopes, owner, oauth_client):
    """Check that scopes to be assigned to a token
    are in fact

    Arguments:
      scopes: raw or expanded scopes
      owner: orm.User or orm.Service

    raises:
        ValueError: if requested scopes exceed owner's assigned scopes
    """
    scopes = set(scopes)
    if scopes.issubset({"inherit"}):
        # nothing to check for simple 'inherit' scopes
        return
    scopes.discard("inherit")
    # common short circuit
    token_scopes = expand_scopes(scopes, owner=owner, oauth_client=oauth_client)

    if not token_scopes:
        return

    owner_scopes = get_scopes_for(owner)
    intersection = _intersect_expanded_scopes(
        token_scopes,
        owner_scopes,
        db=sa.inspect(owner).session,
    )
    excess_scopes = token_scopes - intersection

    if excess_scopes:
        raise ValueError(
            f"Not assigning requested scopes {','.join(excess_scopes)} not held by {owner.__class__.__name__} {owner.name}"
        )


@lru_cache_key(frozenset)
def parse_scopes(scope_list):
    """
    Parses scopes and filters in something akin to JSON style

    For instance, scope list ["users", "groups!group=foo", "servers!server=user/bar", "servers!server=user/baz"]
    would lead to scope model
    {
       "users":scope.ALL,
       "admin:users":{
          "user":[
             "alice"
          ]
       },
       "servers":{
          "server":[
             "user/bar",
             "user/baz"
          ]
       }
    }
    """
    parsed_scopes = {}
    for scope in scope_list:
        base_scope, _, filter_ = scope.partition('!')
        if not filter_:
            parsed_scopes[base_scope] = Scope.ALL
        elif base_scope not in parsed_scopes:
            parsed_scopes[base_scope] = {}

        if parsed_scopes[base_scope] != Scope.ALL:
            key, _, value = filter_.partition('=')
            if key not in parsed_scopes[base_scope]:
                parsed_scopes[base_scope][key] = {value}
            else:
                parsed_scopes[base_scope][key].add(value)
    # return immutable FrozenDict because the result is cached
    return FrozenDict(parsed_scopes)


@lru_cache_key(FrozenDict)
def unparse_scopes(parsed_scopes):
    """Turn a parsed_scopes dictionary back into a expanded scopes set"""
    expanded_scopes = set()
    for base, filters in parsed_scopes.items():
        if filters == Scope.ALL:
            expanded_scopes.add(base)
        else:
            for entity, names_list in filters.items():
                for name in names_list:
                    expanded_scopes.add(f'{base}!{entity}={name}')
    # return immutable frozenset because the result is cached
    return frozenset(expanded_scopes)


@lru_cache_key(frozenset)
def reduce_scopes(expanded_scopes):
    """Reduce expanded scopes to minimal set

    Eliminates overlapping scopes, such as access:services and access:services!service=x
    """
    # unparse_scopes already returns a frozenset
    return unparse_scopes(parse_scopes(expanded_scopes))


def needs_scope(*scopes):
    """Decorator to restrict access to users or services with the required scope"""

    for scope in scopes:
        if scope not in scope_definitions:
            raise ValueError(f"Scope {scope} is not a valid scope")

    def scope_decorator(func):
        @functools.wraps(func)
        def _auth_func(self, *args, **kwargs):
            if not self.current_user:
                # not authenticated at all, fail with more generic message
                # this is the most likely permission error - missing or mis-specified credentials,
                # don't indicate that they have insufficient permissions.
                raise web.HTTPError(
                    403,
                    "Missing or invalid credentials.",
                )

            sig = inspect.signature(func)
            bound_sig = sig.bind(self, *args, **kwargs)
            bound_sig.apply_defaults()
            # Load scopes in case they haven't been loaded yet
            if not hasattr(self, 'expanded_scopes'):
                self.expanded_scopes = {}
                self.parsed_scopes = {}

            try:
                end_point = self.request.path
            except AttributeError:
                end_point = self.__name__

            s_kwargs = {}
            for resource in {'user', 'server', 'group', 'service'}:
                resource_name = resource + '_name'
                if resource_name in bound_sig.arguments:
                    resource_value = bound_sig.arguments[resource_name]
                    s_kwargs[resource] = resource_value
            for scope in scopes:
                app_log.debug("Checking access to %s via scope %s", end_point, scope)
                has_access = _check_scope_access(self, scope, **s_kwargs)
                if has_access:
                    return func(self, *args, **kwargs)
            app_log.warning(
                "Not authorizing access to {}. Requires any of [{}], not derived from scopes [{}]".format(
                    end_point, ", ".join(scopes), ", ".join(self.expanded_scopes)
                )
            )
            raise web.HTTPError(
                403,
                "Action is not authorized with current scopes; requires any of [{}]".format(
                    ", ".join(scopes)
                ),
            )

        return _auth_func

    return scope_decorator


def _identify_key(obj=None):
    if obj is None:
        return None
    else:
        return (type(obj).__name__, obj.name)


@lru_cache_key(_identify_key)
def identify_scopes(obj=None):
    """Return 'identify' scopes for an orm object

    Arguments:
      obj (optional): orm.User or orm.Service
          If not specified, 'raw' scopes for identifying the current user are returned,
          which may need to be expanded, later.

    Returns:
      identify scopes (set): set of scopes needed for 'identify' endpoints
    """
    if obj is None:
        return frozenset(f"read:users:{field}!user" for field in {"name", "groups"})
    elif isinstance(obj, orm.User):
        return frozenset(
            f"read:users:{field}!user={obj.name}" for field in {"name", "groups"}
        )
    elif isinstance(obj, orm.Service):
        return frozenset(
            f"read:services:{field}!service={obj.name}" for field in {"name"}
        )
    else:
        raise TypeError(f"Expected orm.User or orm.Service, got {obj!r}")


@lru_cache_key(lambda oauth_client: oauth_client.identifier)
def access_scopes(oauth_client):
    """Return scope(s) required to access an oauth client"""
    scopes = set()
    if oauth_client.identifier == "jupyterhub":
        return frozenset()
    spawner = oauth_client.spawner
    if spawner:
        scopes.add(f"access:servers!server={spawner.user.name}/{spawner.name}")
    else:
        service = oauth_client.service
        if service:
            scopes.add(f"access:services!service={service.name}")
        else:
            app_log.warning(
                f"OAuth client {oauth_client} has no associated service or spawner!"
            )
    return frozenset(scopes)


def _check_scope_key(sub_scope, orm_resource, kind):
    """Cache key function for check_scope_filter"""
    if kind == 'server':
        resource_key = (orm_resource.user.name, orm_resource.name)
    else:
        resource_key = orm_resource.name
    return (sub_scope, resource_key, kind)


@lru_cache_key(_check_scope_key)
def check_scope_filter(sub_scope, orm_resource, kind):
    """Return whether a sub_scope filter applies to a given resource.

    param sub_scope: parsed_scopes filter (i.e. dict or Scope.ALL)
    param orm_resource: User or Service or Group or Spawner
    param kind: 'user' or 'service' or 'group' or 'server'.

    Returns True or False
    """
    if sub_scope is Scope.ALL:
        return True
    elif kind in sub_scope and orm_resource.name in sub_scope[kind]:
        return True

    if kind == 'server':
        server_format = f"{orm_resource.user.name}/{orm_resource.name}"
        if server_format in sub_scope.get(kind, []):
            return True
        # Fall back on checking if we have user access
        if 'user' in sub_scope and orm_resource.user.name in sub_scope['user']:
            return True
        # Fall back on checking if we have group access for this user
        orm_resource = orm_resource.user
        kind = 'user'

    if kind == 'user' and 'group' in sub_scope:
        group_names = {group.name for group in orm_resource.groups}
        user_in_group = bool(group_names & set(sub_scope['group']))
        # cannot cache if we needed to lookup groups in db
        return DoNotCache(user_in_group)
    return False


def describe_parsed_scopes(parsed_scopes, username=None):
    """Return list of descriptions of parsed scopes

    Highly detailed, often redundant descriptions
    """
    descriptions = []
    for scope, filters in parsed_scopes.items():
        base_text = scope_definitions[scope]["description"]
        if filters == Scope.ALL:
            # no filter
            filter_text = ""
        else:
            filter_chunks = []
            for kind, names in filters.items():
                if kind == 'user' and names == {username}:
                    filter_chunks.append("only you")
                else:
                    kind_text = kind
                    if kind == 'group':
                        kind_text = "users in group"
                    if len(names) == 1:
                        filter_chunks.append(f"{kind}: {list(names)[0]}")
                    else:
                        filter_chunks.append(f"{kind}s: {', '.join(names)}")
            filter_text = "; or ".join(filter_chunks)
        descriptions.append(
            {
                "scope": scope,
                "description": scope_definitions[scope]["description"],
                "filter": filter_text,
            }
        )
    return descriptions


@lru_cache_key(lambda raw_scopes, username=None: (frozenset(raw_scopes), username))
def describe_raw_scopes(raw_scopes, username=None):
    """Return list of descriptions of raw scopes

    A much shorter list than describe_parsed_scopes
    """
    descriptions = []
    for raw_scope in raw_scopes:
        scope, _, filter_ = raw_scope.partition("!")
        base_text = scope_definitions[scope]["description"]
        if not filter_:
            # no filter
            filter_text = ""
        elif filter_ == "user":
            filter_text = "only you"
        else:
            kind, _, name = filter_.partition("=")
            if kind == "user" and name == username:
                filter_text = "only you"
            else:
                kind_text = kind
                if kind == 'group':
                    kind_text = "users in group"
                filter_text = f"{kind_text} {name}"
        descriptions.append(
            {
                "scope": scope,
                "description": scope_definitions[scope]["description"],
                "filter": filter_text,
            }
        )
    # make sure we return immutable from a cached function
    return tuple(descriptions)


# regex for custom scope
# for-humans description below
# note: scope description duplicated in docs/source/rbac/scopes.md
# update docs when making changes here
_custom_scope_pattern = re.compile(r"^custom:[a-z0-9][a-z0-9_\-\*:]+[a-z0-9_\*]$")

# custom scope pattern description
# used in docstring below and error message when scopes don't match _custom_scope_pattern
_custom_scope_description = """
Custom scopes must start with `custom:`
and contain only lowercase ascii letters, numbers, hyphen, underscore, colon, and asterisk (-_:*).
The part after `custom:` must start with a letter or number.
Scopes may not end with a hyphen or colon.
"""


def define_custom_scopes(scopes):
    """Define custom scopes

    Adds custom scopes to the scope_definitions dict.

    Scopes must start with `custom:`.
    It is recommended to name custom scopes with a pattern like::

        custom:$your-project:$action:$resource

    e.g.::

        custom:jupyter_server:read:contents

    That makes them easy to parse and avoids collisions across projects.

    `scopes` must have at least one scope definition,
    and each scope definition must have a `description`,
    which will be displayed on the oauth authorization page,
    and _may_ have a `subscopes` list of other scopes if having one scope
    should imply having other, more specific scopes.

    Args:

    scopes: dict
        A dictionary of scope definitions.
        The keys are the scopes,
        while the values are dictionaries with at least a `description` field,
        and optional `subscopes` field.
        %s
    Examples::

        define_custom_scopes(
            {
                "custom:jupyter_server:read:contents": {
                    "description": "read-only access to files in a Jupyter server",
                },
                "custom:jupyter_server:read": {
                    "description": "read-only access to a Jupyter server",
                    "subscopes": [
                        "custom:jupyter_server:read:contents",
                        "custom:jupyter_server:read:kernels",
                        "...",
                },
            }
        )
    """ % indent(
        _custom_scope_description, " " * 8
    )
    for scope, scope_definition in scopes.items():
        if scope in scope_definitions and scope_definitions[scope] != scope_definition:
            raise ValueError(
                f"Cannot redefine scope {scope}={scope_definition}. Already have {scope}={scope_definitions[scope]}"
            )
        if not _custom_scope_pattern.match(scope):
            # note: keep this description in sync with docstring above
            raise ValueError(
                f"Invalid scope name: {scope!r}.\n{_custom_scope_description}"
                " and contain only lowercase ascii letters, numbers, hyphen, underscore, colon, and asterisk."
                " The part after `custom:` must start with a letter or number."
                " Scopes may not end with a hyphen or colon."
            )
        if "description" not in scope_definition:
            raise ValueError(
                f"scope {scope}={scope_definition} missing key 'description'"
            )
        if "subscopes" in scope_definition:
            subscopes = scope_definition["subscopes"]
            if not isinstance(subscopes, list) or not all(
                isinstance(s, str) for s in subscopes
            ):
                raise ValueError(
                    f"subscopes must be a list of scope strings, got {subscopes!r}"
                )
            for subscope in subscopes:
                if subscope not in scopes:
                    if subscope in scope_definitions:
                        raise ValueError(
                            f"non-custom subscope {subscope} in {scope}={scope_definition} is not allowed."
                            f" Custom scopes may only have custom subscopes."
                            f" Roles should be used to assign multiple scopes together."
                        )
                    raise ValueError(
                        f"subscope {subscope} in {scope}={scope_definition} not found. All scopes must be defined."
                    )

        extra_keys = set(scope_definition.keys()).difference(
            ["description", "subscopes"]
        )
        if extra_keys:
            warnings.warn(
                f"Ignoring unrecognized key(s) {', '.join(extra_keys)!r} in {scope}={scope_definition}",
                UserWarning,
                stacklevel=2,
            )
        app_log.info(f"Defining custom scope {scope}")
        # deferred evaluation for debug-logging
        app_log.debug("Defining custom scope %s=%s", scope, scope_definition)
        scope_definitions[scope] = scope_definition
