# Copyright 2013 Metacloud, Inc.
# Copyright 2012 OpenStack Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

from __future__ import absolute_import
import copy

from oslo.utils import timeutils
import six

from keystone.common import kvs
from keystone import config
from keystone import exception
from keystone.i18n import _
from keystone.openstack.common import log
from keystone import token
from keystone.token import provider


CONF = config.CONF
LOG = log.getLogger(__name__)


class Token(token.persistence.Driver):
    """KeyValueStore backend for tokens.

    This is the base implementation for any/all key-value-stores (e.g.
    memcached) for the Token backend.  It is recommended to only use the base
    in-memory implementation for testing purposes.
    """

    revocation_key = 'revocation-list'
    kvs_backend = 'openstack.kvs.Memory'

    def __init__(self, backing_store=None, **kwargs):
        super(Token, self).__init__()
        self._store = kvs.get_key_value_store('token-driver')
        if backing_store is not None:
            self.kvs_backend = backing_store
        if not self._store.is_configured:
            # Do not re-configure the backend if the store has been initialized
            self._store.configure(backing_store=self.kvs_backend, **kwargs)
        if self.__class__ == Token:
            # NOTE(morganfainberg): Only warn if the base KVS implementation
            # is instantiated.
            LOG.warn(_('It is recommended to only use the base '
                       'key-value-store implementation for the token driver '
                       'for testing purposes. Please use '
                       'keystone.token.persistence.backends.memcache.Token '
                       'or keystone.token.persistence.backends.sql.Token '
                       'instead.'))

    def _prefix_token_id(self, token_id):
        return 'token-%s' % token_id.encode('utf-8')

    def _prefix_user_id(self, user_id):
        return 'usertokens-%s' % user_id.encode('utf-8')

    def _get_key_or_default(self, key, default=None):
        try:
            return self._store.get(key)
        except exception.NotFound:
            return default

    def _get_key(self, key):
        return self._store.get(key)

    def _set_key(self, key, value, lock=None):
        self._store.set(key, value, lock)

    def _delete_key(self, key):
        return self._store.delete(key)

    def get_token(self, token_id):
        ptk = self._prefix_token_id(token_id)
        try:
            token_ref = self._get_key(ptk)
        except exception.NotFound:
            raise exception.TokenNotFound(token_id=token_id)

        return token_ref

    def create_token(self, token_id, data):
        """Create a token by id and data.

        It is assumed the caller has performed data validation on the "data"
        parameter.
        """
        data_copy = copy.deepcopy(data)
        ptk = self._prefix_token_id(token_id)
        if not data_copy.get('expires'):
            data_copy['expires'] = provider.default_expire_time()
        if not data_copy.get('user_id'):
            data_copy['user_id'] = data_copy['user']['id']

        # NOTE(morganfainberg): for ease of manipulating the data without
        # concern about the backend, always store the value(s) in the
        # index as the isotime (string) version so this is where the string is
        # built.
        expires_str = timeutils.isotime(data_copy['expires'], subsecond=True)

        self._set_key(ptk, data_copy)
        user_id = data['user']['id']
        user_key = self._prefix_user_id(user_id)
        self._update_user_token_list(user_key, token_id, expires_str)
        if CONF.trust.enabled and data.get('trust_id'):
            # NOTE(morganfainberg): If trusts are enabled and this is a trust
            # scoped token, we add the token to the trustee list as well.  This
            # allows password changes of the trustee to also expire the token.
            # There is no harm in placing the token in multiple lists, as
            # _list_tokens is smart enough to handle almost any case of
            # valid/invalid/expired for a given token.
            token_data = data_copy['token_data']
            if data_copy['token_version'] == token.provider.V2:
                trustee_user_id = token_data['access']['trust'][
                    'trustee_user_id']
            elif data_copy['token_version'] == token.provider.V3:
                trustee_user_id = token_data['OS-TRUST:trust'][
                    'trustee_user_id']
            else:
                raise exception.UnsupportedTokenVersionException(
                    _('Unknown token version %s') %
                    data_copy.get('token_version'))

            trustee_key = self._prefix_user_id(trustee_user_id)
            self._update_user_token_list(trustee_key, token_id, expires_str)

        return data_copy

    def _get_user_token_list_with_expiry(self, user_key):
        """Return a list of tuples in the format (token_id, token_expiry) for
        the user_key.
        """
        return self._get_key_or_default(user_key, default=[])

    def _get_user_token_list(self, user_key):
        """Return a list of token_ids for the user_key."""
        token_list = self._get_user_token_list_with_expiry(user_key)
        # Each element is a tuple of (token_id, token_expiry). Most code does
        # not care about the expiry, it is stripped out and only a
        # list of token_ids are returned.
        return [t[0] for t in token_list]

    def _update_user_token_list(self, user_key, token_id, expires_isotime_str):
        current_time = self._get_current_time()
        revoked_token_list = set([t['id'] for t in
                                  self.list_revoked_tokens()])

        with self._store.get_lock(user_key) as lock:
            filtered_list = []
            token_list = self._get_user_token_list_with_expiry(user_key)
            for item in token_list:
                try:
                    item_id, expires = self._format_token_index_item(item)
                except (ValueError, TypeError):
                    # NOTE(morganfainberg): Skip on expected errors
                    # possibilities from the `_format_token_index_item` method.
                    continue

                if expires < current_time:
                    LOG.debug(('Token `%(token_id)s` is expired, removing '
                               'from `%(user_key)s`.'),
                              {'token_id': item_id, 'user_key': user_key})
                    continue

                if item_id in revoked_token_list:
                    # NOTE(morganfainberg): If the token has been revoked, it
                    # can safely be removed from this list.  This helps to keep
                    # the user_token_list as reasonably small as possible.
                    LOG.debug(('Token `%(token_id)s` is revoked, removing '
                               'from `%(user_key)s`.'),
                              {'token_id': item_id, 'user_key': user_key})
                    continue
                filtered_list.append(item)
            filtered_list.append((token_id, expires_isotime_str))
            self._set_key(user_key, filtered_list, lock)
            return filtered_list

    def _get_current_time(self):
        return timeutils.normalize_time(timeutils.utcnow())

    def _add_to_revocation_list(self, data, lock):
        filtered_list = []
        revoked_token_data = {}

        current_time = self._get_current_time()
        expires = data['expires']

        if isinstance(expires, six.string_types):
            expires = timeutils.parse_isotime(expires)

        expires = timeutils.normalize_time(expires)

        if expires < current_time:
            LOG.warning(_('Token `%s` is expired, not adding to the '
                          'revocation list.'), data['id'])
            return

        revoked_token_data['expires'] = timeutils.isotime(expires,
                                                          subsecond=True)
        revoked_token_data['id'] = data['id']

        token_list = self._get_key_or_default(self.revocation_key, default=[])
        if not isinstance(token_list, list):
            # NOTE(morganfainberg): In the case that the revocation list is not
            # in a format we understand, reinitialize it. This is an attempt to
            # not allow the revocation list to be completely broken if
            # somehow the key is changed outside of keystone (e.g. memcache
            # that is shared by multiple applications). Logging occurs at error
            # level so that the cloud administrators have some awareness that
            # the revocation_list needed to be cleared out. In all, this should
            # be recoverable. Keystone cannot control external applications
            # from changing a key in some backends, however, it is possible to
            # gracefully handle and notify of this event.
            LOG.error(_('Reinitializing revocation list due to error '
                        'in loading revocation list from backend.  '
                        'Expected `list` type got `%(type)s`. Old '
                        'revocation list data: %(list)r'),
                      {'type': type(token_list), 'list': token_list})
            token_list = []

        # NOTE(morganfainberg): on revocation, cleanup the expired entries, try
        # to keep the list of tokens revoked at the minimum.
        for token_data in token_list:
            try:
                expires_at = timeutils.normalize_time(
                    timeutils.parse_isotime(token_data['expires']))
            except ValueError:
                LOG.warning(_('Removing `%s` from revocation list due to '
                              'invalid expires data in revocation list.'),
                            token_data.get('id', 'INVALID_TOKEN_DATA'))
                continue
            if expires_at > current_time:
                filtered_list.append(token_data)
        filtered_list.append(revoked_token_data)
        self._set_key(self.revocation_key, filtered_list, lock)

    def delete_token(self, token_id):
        # Test for existence
        with self._store.get_lock(self.revocation_key) as lock:
            data = self.get_token(token_id)
            ptk = self._prefix_token_id(token_id)
            result = self._delete_key(ptk)
            self._add_to_revocation_list(data, lock)
        return result

    def delete_tokens(self, user_id, tenant_id=None, trust_id=None,
                      consumer_id=None):
        return super(Token, self).delete_tokens(
            user_id=user_id,
            tenant_id=tenant_id,
            trust_id=trust_id,
            consumer_id=consumer_id,
        )

    def _format_token_index_item(self, item):
        try:
            token_id, expires = item
        except (TypeError, ValueError):
            LOG.debug(('Invalid token entry expected tuple of '
                       '`(<token_id>, <expires>)` got: `%(item)r`'),
                      dict(item=item))
            raise

        try:
            expires = timeutils.normalize_time(
                timeutils.parse_isotime(expires))
        except ValueError:
            LOG.debug(('Invalid expires time on token `%(token_id)s`:'
                       ' %(expires)r'),
                      dict(token_id=token_id, expires=expires))
            raise
        return token_id, expires

    def _token_match_tenant(self, token_ref, tenant_id):
        if token_ref.get('tenant'):
            return token_ref['tenant'].get('id') == tenant_id
        return False

    def _token_match_trust(self, token_ref, trust_id):
        if not token_ref.get('trust_id'):
            return False
        return token_ref['trust_id'] == trust_id

    def _token_match_consumer(self, token_ref, consumer_id):
        try:
            oauth = token_ref['token_data']['token']['OS-OAUTH1']
            return oauth.get('consumer_id') == consumer_id
        except KeyError:
            return False

    def _list_tokens(self, user_id, tenant_id=None, trust_id=None,
                     consumer_id=None):
        # This function is used to generate the list of tokens that should be
        # revoked when revoking by token identifiers.  This approach will be
        # deprecated soon, probably in the Juno release.  Setting revoke_by_id
        # to False indicates that this kind of recording should not be
        # performed.  In order to test the revocation events, tokens shouldn't
        # be deleted from the backends.  This check ensures that tokens are
        # still recorded.
        if not CONF.token.revoke_by_id:
            return []
        tokens = []
        user_key = self._prefix_user_id(user_id)
        token_list = self._get_user_token_list_with_expiry(user_key)
        current_time = self._get_current_time()
        for item in token_list:
            try:
                token_id, expires = self._format_token_index_item(item)
            except (TypeError, ValueError):
                # NOTE(morganfainberg): Skip on expected error possibilities
                # from the `_format_token_index_item` method.
                continue

            if expires < current_time:
                continue

            try:
                token_ref = self.get_token(token_id)
            except exception.TokenNotFound:
                # NOTE(morganfainberg): Token doesn't exist, skip it.
                continue
            if token_ref:
                if tenant_id is not None:
                    if not self._token_match_tenant(token_ref, tenant_id):
                        continue
                if trust_id is not None:
                    if not self._token_match_trust(token_ref, trust_id):
                        continue
                if consumer_id is not None:
                    if not self._token_match_consumer(token_ref, consumer_id):
                        continue

                tokens.append(token_id)
        return tokens

    def list_revoked_tokens(self):
        revoked_token_list = self._get_key_or_default(self.revocation_key,
                                                      default=[])
        if isinstance(revoked_token_list, list):
            return revoked_token_list
        return []

    def flush_expired_tokens(self):
        """Archive or delete tokens that have expired."""
        raise exception.NotImplemented()
