# Copyright 2017 Canonical Ltd.
# Licensed under the LGPLv3, see LICENCE file for details.

import base64
import hashlib
import itertools
import os

import google
from ._checker import (Op, LOGIN_OP)
from ._store import MemoryKeyStore
from ._error import VerificationError
from ._versions import (
    VERSION_2,
    VERSION_3,
    LATEST_VERSION,
)
from ._macaroon import (
    Macaroon,
    macaroon_version,
)

import macaroonbakery.checkers as checkers
import six
from macaroonbakery._utils import (
    raw_urlsafe_b64encode,
    b64decode,
)
from ._internal import id_pb2
from pymacaroons import MACAROON_V2, Verifier


class Oven:
    ''' Oven bakes macaroons. They emerge sweet and delicious and ready for use
    in a Checker.

    All macaroons are associated with one or more operations (see
    the Op type) which define the capabilities of the macaroon.

    There is one special operation, "login" (defined by LOGIN_OP) which grants
    the capability to speak for a particular user.
    The login capability will never be mixed with other capabilities.

    It is up to the caller to decide on semantics for other operations.
    '''

    def __init__(self, key=None, location=None, locator=None, namespace=None,
                 root_keystore_for_ops=None, ops_store=None):
        '''
        @param namespace holds the namespace to use when adding first party
        caveats.
        @param root_keystore_for_ops a function that will give the macaroon
        storage to be used for root keys associated with macaroons created
        with macaroon.
        @param ops_store object is used to persistently store the association
        of multi-op entities with their associated operations when macaroon is
        called with multiple operations.
        When this is in use, operation entities with the prefix "multi-" are
        reserved - a "multi-"-prefixed entity represents a set of operations
        stored in the OpsStore.
        @param key holds the private nacl key pair used to encrypt third party
        caveats. If it is None, no third party caveats can be created.
        @param location string holds the location that will be associated with
        new macaroons (as returned by Macaroon.Location).
        @param locator is used to find out information on third parties when
        adding third party caveats. If this is None, no non-local third
        party caveats can be added.
        '''
        self.key = key
        self.location = location
        self.locator = locator
        if namespace is None:
            namespace = checkers.Checker().namespace()
        self.namespace = namespace
        self.ops_store = ops_store
        self.root_keystore_for_ops = root_keystore_for_ops
        if root_keystore_for_ops is None:
            my_store = MemoryKeyStore()
            self.root_keystore_for_ops = lambda x: my_store

    def macaroon(self, version, expiry, caveats, ops):
        ''' Takes a macaroon with the given version from the oven,
        associates it with the given operations and attaches the given caveats.
        There must be at least one operation specified.
        The macaroon will expire at the given time - a time_before first party
        caveat will be added with that time.

        @return: a new Macaroon object.
        '''
        if len(ops) == 0:
            raise ValueError('cannot mint a macaroon associated '
                             'with no operations')

        ops = canonical_ops(ops)
        root_key, storage_id = self.root_keystore_for_ops(ops).root_key()

        id = self._new_macaroon_id(storage_id, expiry, ops)

        id_bytes = six.int2byte(LATEST_VERSION) + \
            id.SerializeToString()

        if macaroon_version(version) < MACAROON_V2:
            # The old macaroon format required valid text for the macaroon id,
            # so base64-encode it.
            id_bytes = raw_urlsafe_b64encode(id_bytes)

        m = Macaroon(
            root_key,
            id_bytes,
            self.location,
            version,
            self.namespace,
        )
        m.add_caveat(checkers.time_before_caveat(expiry), self.key,
                     self.locator)
        m.add_caveats(caveats, self.key, self.locator)
        return m

    def _new_macaroon_id(self, storage_id, expiry, ops):
        nonce = os.urandom(16)
        if len(ops) == 1 or self.ops_store is None:
            return id_pb2.MacaroonId(
                nonce=nonce,
                storageId=storage_id,
                ops=_macaroon_id_ops(ops))
        # We've got several operations and a multi-op store, so use the store.
        # TODO use the store only if the encoded macaroon id exceeds some size?
        entity = self.ops_entity(ops)
        self.ops_store.put_ops(entity, expiry, ops)
        return id_pb2.MacaroonId(
            nonce=nonce,
            storageId=storage_id,
            ops=[id_pb2.Op(entity=entity, actions=['*'])])

    def ops_entity(self, ops):
        ''' Returns a new multi-op entity name string that represents
        all the given operations and caveats. It returns the same value
        regardless of the ordering of the operations. It assumes that the
        operations have been canonicalized and that there's at least one
        operation.

        :param ops:
        :return: string that represents all the given operations and caveats.
        '''
        # Hash the operations, removing duplicates as we go.
        hash_entity = hashlib.sha256()
        for op in ops:
            hash_entity.update('{}\n{}\n'.format(
                op.action, op.entity).encode())
        hash_encoded = base64.urlsafe_b64encode(hash_entity.digest())
        return 'multi-' + hash_encoded.decode('utf-8').rstrip('=')

    def macaroon_ops(self, macaroons):
        ''' This method makes the oven satisfy the MacaroonOpStore protocol
        required by the Checker class.

        For macaroons minted with previous bakery versions, it always
        returns a single LoginOp operation.

        :param macaroons:
        :return:
        '''
        if len(macaroons) == 0:
            raise ValueError('no macaroons provided')

        storage_id, ops = _decode_macaroon_id(macaroons[0].identifier_bytes)
        root_key = self.root_keystore_for_ops(ops).get(storage_id)
        if root_key is None:
            raise VerificationError(
                'macaroon key not found in storage')
        v = Verifier()
        conditions = []

        def validator(condition):
            # Verify the macaroon's signature only. Don't check any of the
            # caveats yet but save them so that we can return them.
            conditions.append(condition)
            return True
        v.satisfy_general(validator)
        try:
            v.verify(macaroons[0], root_key, macaroons[1:])
        except Exception as exc:
            # Unfortunately pymacaroons doesn't control
            # the set of exceptions that can be raised here.
            # Possible candidates are:
            # pymacaroons.exceptions.MacaroonUnmetCaveatException
            # pymacaroons.exceptions.MacaroonInvalidSignatureException
            # ValueError
            # nacl.exceptions.CryptoError
            #
            # There may be others too, so just catch everything.
            raise six.raise_from(
                VerificationError('verification failed: {}'.format(str(exc))),
                exc,
            )

        if (self.ops_store is not None
            and len(ops) == 1
                and ops[0].entity.startswith('multi-')):
            # It's a multi-op entity, so retrieve the actual operations
            # it's associated with.
            ops = self.ops_store.get_ops(ops[0].entity)

        return ops, conditions


def _decode_macaroon_id(id):
    storage_id = b''
    base64_decoded = False
    first = id[:1]
    if first == b'A':
        # The first byte is not a version number and it's 'A', which is the
        # base64 encoding of the top 6 bits (all zero) of the version number 2
        # or 3, so we assume that it's the base64 encoding of a new-style
        # macaroon id, so we base64 decode it.
        #
        # Note that old-style ids always start with an ASCII character >= 4
        # (> 32 in fact) so this logic won't be triggered for those.
        try:
            dec = b64decode(id.decode('utf-8'))
            # Set the id only on success.
            id = dec
            base64_decoded = True
        except:
            # if it's a bad encoding, we'll get an error which is fine
            pass

    # Trim any extraneous information from the id before retrieving
    # it from storage, including the UUID that's added when
    # creating macaroons to make all macaroons unique even if
    # they're using the same root key.
    first = six.byte2int(id[:1])
    if first == VERSION_2:
        # Skip the UUID at the start of the id.
        storage_id = id[1 + 16:]
    if first == VERSION_3:
        try:
            id1 = id_pb2.MacaroonId.FromString(id[1:])
        except google.protobuf.message.DecodeError:
            raise VerificationError(
                'no operations found in macaroon')
        if len(id1.ops) == 0 or len(id1.ops[0].actions) == 0:
            raise VerificationError(
                'no operations found in macaroon')

        ops = []
        for op in id1.ops:
            for action in op.actions:
                ops.append(Op(op.entity, action))
        return id1.storageId, ops

    if not base64_decoded and _is_lower_case_hex_char(first):
        # It's an old-style id, probably with a hyphenated UUID.
        # so trim that off.
        last = id.rfind(b'-')
        if last >= 0:
            storage_id = id[0:last]
    return storage_id, [LOGIN_OP]


def _is_lower_case_hex_char(b):
    if ord('0') <= b <= ord('9'):
        return True
    if ord('a') <= b <= ord('f'):
        return True
    return False


def canonical_ops(ops):
    ''' Returns the given operations array sorted with duplicates removed.

    @param ops checker.Ops
    @return: checker.Ops
    '''
    new_ops = sorted(set(ops), key=lambda x: (x.entity, x.action))
    return new_ops


def _macaroon_id_ops(ops):
    '''Return operations suitable for serializing as part of a MacaroonId.

    It assumes that ops has been canonicalized and that there's at least
    one operation.
    '''
    id_ops = []
    for entity, entity_ops in itertools.groupby(ops, lambda x: x.entity):
        actions = map(lambda x: x.action, entity_ops)
        id_ops.append(id_pb2.Op(entity=entity, actions=actions))
    return id_ops
