# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.
from collections import namedtuple
from threading import Lock

from ._authorizer import ClosedAuthorizer
from ._identity import NoIdentities
from ._error import (
    AuthInitError,
    VerificationError,
    IdentityError,
    DischargeRequiredError,
    PermissionDenied,
)
import macaroonbakery.checkers as checkers
import pyrfc3339


class Op(namedtuple('Op', 'entity, action')):
    ''' Op holds an entity and action to be authorized on that entity.
    entity string holds the name of the entity to be authorized.

    @param entity should not contain spaces and should
    not start with the prefix "login" or "multi-" (conventionally,
    entity names will be prefixed with the entity type followed
    by a hyphen.
    @param action string holds the action to perform on the entity,
    such as "read" or "delete". It is up to the service using a checker
    to define a set of operations and keep them consistent over time.
    '''


# LOGIN_OP represents a login (authentication) operation.
# A macaroon that is associated with this operation generally
# carries authentication information with it.
LOGIN_OP = Op(entity='login', action='login')


class Checker(object):
    '''Checker implements an authentication and authorization checker.

    It uses macaroons as authorization tokens but it is not itself responsible
    for creating the macaroons
    See the Oven type (TODO) for one way of doing that.
    '''
    def __init__(self, checker=checkers.Checker(),
                 authorizer=ClosedAuthorizer(),
                 identity_client=None,
                 macaroon_opstore=None):
        '''
        :param checker: a first party checker implementing a
        :param authorizer (Authorizer): used to check whether an authenticated
        user is allowed to perform operations.
        The identity parameter passed to authorizer.allow will always have been
        obtained from a call to identity_client.declared_identity.
        :param identity_client (IdentityClient) used for interactions with the
        external identity service used for authentication.
        If this is None, no authentication will be possible.
        :param macaroon_opstore (object with new_macaroon and macaroon_ops
        method): used to retrieve macaroon root keys and other associated
        information.
        '''
        self._first_party_caveat_checker = checker
        self._authorizer = authorizer
        if identity_client is None:
            identity_client = NoIdentities()
        self._identity_client = identity_client
        self._macaroon_opstore = macaroon_opstore

    def auth(self, mss):
        ''' Returns a new AuthChecker instance using the given macaroons to
        inform authorization decisions.
        @param mss: a list of macaroon lists.
        '''
        return AuthChecker(parent=self,
                           macaroons=mss)

    def namespace(self):
        ''' Returns the namespace of the first party checker.
        '''
        return self._first_party_caveat_checker.namespace()


class AuthChecker(object):
    '''Authorizes operations with respect to a user's request.

    The identity is authenticated only once, the first time any method
    of the AuthChecker is called, using the context passed in then.

    To find out any declared identity without requiring a login,
    use allow(ctx); to require authentication but no additional operations,
    use allow(ctx, LOGIN_OP).
    '''
    def __init__(self, parent, macaroons):
        '''

        :param parent (Checker): used to check first party caveats.
        :param macaroons: a list of py macaroons
        '''
        self._macaroons = macaroons
        self._init_errors = []
        self._executed = False
        self._identity = None
        self._identity_caveats = []
        self.parent = parent
        self._conditions = None
        self._mutex = Lock()

    def _init(self, ctx):
        with self._mutex:
            if not self._executed:
                self._init_once(ctx)
                self._executed = True

    def _init_once(self, ctx):
        self._auth_indexes = {}
        self._conditions = [None] * len(self._macaroons)
        for i, ms in enumerate(self._macaroons):
            try:
                ops, conditions = self.parent._macaroon_opstore.macaroon_ops(ms)
            except VerificationError as e:
                self._init_errors.append(str(e))
                continue
            except Exception as exc:
                raise AuthInitError(str(exc))

            # It's a valid macaroon (in principle - we haven't checked first
            # party caveats).
            self._conditions[i] = conditions
            is_login = False
            for op in ops:
                if op == LOGIN_OP:
                    # Don't associate the macaroon with the login operation
                    # until we've verified that it is valid below
                    is_login = True
                else:
                    if op not in self._auth_indexes:
                        self._auth_indexes[op] = []
                    self._auth_indexes[op].append(i)
            if not is_login:
                continue
            # It's a login macaroon. Check the conditions now -
            # all calls want to see the same authentication
            # information so that callers have a consistent idea of
            # the client's identity.
            #
            # If the conditions fail, we won't use the macaroon for
            # identity, but we can still potentially use it for its
            # other operations if the conditions succeed for those.
            declared, err = self._check_conditions(ctx, LOGIN_OP, conditions)
            if err is not None:
                self._init_errors.append('cannot authorize login macaroon: ' + err)
                continue
            if self._identity is not None:
                # We've already found a login macaroon so ignore this one
                # for the purposes of identity.
                continue

            try:
                identity = self.parent._identity_client.declared_identity(
                    ctx, declared)
            except IdentityError as exc:
                self._init_errors.append(
                    'cannot decode declared identity: {}'.format(exc.args[0]))
                continue
            if LOGIN_OP not in self._auth_indexes:
                self._auth_indexes[LOGIN_OP] = []
            self._auth_indexes[LOGIN_OP].append(i)
            self._identity = identity

        if self._identity is None:
            # No identity yet, so try to get one based on the context.
            try:
                identity, cavs = self.parent.\
                    _identity_client.identity_from_context(ctx)
            except IdentityError:
                self._init_errors.append('could not determine identity')
            if cavs is None:
                cavs = []
            self._identity, self._identity_caveats = identity, cavs
        return None

    def allow(self, ctx, ops):
        ''' Checks that the authorizer's request is authorized to
        perform all the given operations. Note that allow does not check
        first party caveats - if there is more than one macaroon that may
        authorize the request, it will choose the first one that does
        regardless.

        If all the operations are allowed, an AuthInfo is returned holding
        details of the decision and any first party caveats that must be
        checked before actually executing any operation.

        If operations include LOGIN_OP, the request should contain an
        authentication macaroon proving the client's identity. Once an
        authentication macaroon is chosen, it will be used for all other
        authorization requests.

        If an operation was not allowed, an exception will be raised which may
        be:

        - DischargeRequiredError holding the operations that remain to
        be authorized in order to allow authorization to proceed
        - PermissionDenied when no operations can be authorized and there's
        no third party to discharge macaroons for.

        @param ctx AuthContext
        @param ops an array of Op
        :return: an AuthInfo object.
        '''
        auth_info, _ = self.allow_any(ctx, ops)
        return auth_info

    def allow_any(self, ctx, ops):
        ''' like allow except that it will authorize as many of the
        operations as possible without requiring any to be authorized. If all
        the operations succeeded, the array will be nil.

        If any the operations failed, the returned error will be the same
        that allow would return and each element in the returned slice will
        hold whether its respective operation was allowed.

        If all the operations succeeded, the returned slice will be None.

        The returned AuthInfo will always be non-None.

        The LOGIN_OP operation is treated specially - it is always required if
        present in ops.
        @param ctx AuthContext
        @param ops an array of Op
        :return: an AuthInfo object and the auth used as an array of int.
        '''
        authed, used = self._allow_any(ctx, ops)
        return self._new_auth_info(used), authed

    def _new_auth_info(self, used):
        info = AuthInfo(identity=self._identity, macaroons=[])
        for i, is_used in enumerate(used):
            if is_used:
                info.macaroons.append(self._macaroons[i])
        return info

    def _allow_any(self, ctx, ops):
        self._init(ctx)
        used = [False] * len(self._macaroons)
        authed = [False] * len(ops)
        num_authed = 0
        errors = []
        for i, op in enumerate(ops):
            for mindex in self._auth_indexes.get(op, []):
                _, err = self._check_conditions(ctx, op,
                                                self._conditions[mindex])
                if err is not None:
                    errors.append(err)
                    continue
                authed[i] = True
                num_authed += 1
                used[mindex] = True
                # Use the first authorized macaroon only.
                break
            if op == LOGIN_OP and not authed[i] and self._identity is not None:
                # Allow LOGIN_OP when there's an authenticated user even
                # when there's no macaroon that specifically authorizes it.
                authed[i] = True
        if self._identity is not None:
            # We've authenticated as a user, so even if the operations didn't
            # specifically require it, we add the login macaroon
            # to the macaroons used.
            # Note that the LOGIN_OP conditions have already been checked
            # successfully in initOnceFunc so no need to check again.
            # Note also that there may not be any macaroons if the
            # identity client decided on an identity even with no
            # macaroons.
            for i in self._auth_indexes.get(LOGIN_OP, []):
                used[i] = True
        if num_authed == len(ops):
            # All operations allowed.
            return authed, used
        # There are some unauthorized operations.
        need = []
        need_index = [0] * (len(ops) - num_authed)
        for i, ok in enumerate(authed):
            if not ok:
                need_index[len(need)] = i
                need.append(ops[i])

        # Try to authorize the operations
        # even if we haven't got an authenticated user.
        oks, caveats = self.parent._authorizer.authorize(
            ctx, self._identity, need)
        still_need = []
        for i, _ in enumerate(need):
            if i < len(oks) and oks[i]:
                authed[need_index[i]] = True
            else:
                still_need.append(ops[need_index[i]])
        if len(still_need) == 0 and len(caveats) == 0:
            # No more ops need to be authenticated and
            # no caveats to be discharged.
            return authed, used
        if self._identity is None and len(self._identity_caveats) > 0:
            raise DischargeRequiredError(
                msg='authentication required',
                ops=[LOGIN_OP],
                cavs=self._identity_caveats)
        if caveats is None or len(caveats) == 0:
            all_errors = []
            all_errors.extend(self._init_errors)
            all_errors.extend(errors)
            err = ''
            if len(all_errors) > 0:
                err = all_errors[0]
            raise PermissionDenied(err)
        raise DischargeRequiredError(
            msg='some operations have extra caveats', ops=ops, cavs=caveats)

    def allow_capability(self, ctx, ops):
        '''Checks that the user is allowed to perform all the
        given operations. If not, a discharge error will be raised.
        If allow_capability succeeds, it returns a list of first party caveat
        conditions that must be applied to any macaroon granting capability
        to execute the operations. Those caveat conditions will not
        include any declarations contained in login macaroons - the
        caller must be careful not to mint a macaroon associated
        with the LOGIN_OP operation unless they add the expected
        declaration caveat too - in general, clients should not create
        capabilities that grant LOGIN_OP rights.

        The operations must include at least one non-LOGIN_OP operation.
        '''
        nops = 0
        for op in ops:
            if op != LOGIN_OP:
                nops += 1
        if nops == 0:
            raise ValueError('no non-login operations required in capability')

        _, used = self._allow_any(ctx, ops)
        squasher = _CaveatSquasher()
        for i, is_used in enumerate(used):
            if not is_used:
                continue
            for cond in self._conditions[i]:
                squasher.add(cond)
        return squasher.final()

    def _check_conditions(self, ctx, op, conds):
        declared = checkers.infer_declared_from_conditions(
            conds,
            self.parent.namespace())
        ctx = checkers.context_with_operations(ctx, [op.action])
        ctx = checkers.context_with_declared(ctx, declared)
        for cond in conds:
            err = self.parent._first_party_caveat_checker.\
                check_first_party_caveat(ctx, cond)
            if err is not None:
                return None, err
        return declared, None


class AuthInfo(namedtuple('AuthInfo', 'identity macaroons')):
    '''AuthInfo information about an authorization decision.

    @param identity: holds information on the authenticated user as
    returned identity_client. It may be None after a successful
    authorization if LOGIN_OP access was not required.

    @param macaroons: holds all the macaroons that were used for the
    authorization. Macaroons that were invalid or unnecessary are
    not included.
    '''


class _CaveatSquasher(object):
    ''' Rationalizes first party caveats created for a capability by:
        - including only the earliest time-before caveat.
        - excluding allow and deny caveats (operations are checked by
        virtue of the operations associated with the macaroon).
        - removing declared caveats.
        - removing duplicates.
    '''
    def __init__(self, expiry=None, conds=None):
        self._expiry = expiry
        if conds is None:
            conds = []
        self._conds = conds

    def add(self, cond):
        if self._add(cond):
            self._conds.append(cond)

    def _add(self, cond):
        try:
            cond, args = checkers.parse_caveat(cond)
        except ValueError:
            # Be safe - if we can't parse the caveat, just leave it there.
            return True

        if cond == checkers.COND_TIME_BEFORE:
            try:
                et = pyrfc3339.parse(args, utc=True).replace(tzinfo=None)
            except ValueError:
                # Again, if it doesn't seem valid, leave it alone.
                return True
            if self._expiry is None or et <= self._expiry:
                self._expiry = et
            return False
        elif cond in [checkers.COND_ALLOW,
                      checkers.COND_DENY, checkers.COND_DECLARED]:
            return False
        return True

    def final(self):
        if self._expiry is not None:
            self._conds.append(
                checkers.time_before_caveat(self._expiry).condition)
        # Make deterministic and eliminate duplicates.
        return sorted(set(self._conds))
