# Copyright 2014 OpenStack Foundation
# All Rights Reserved.
#
#    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 collections import abc
import copy
import functools

from cryptography import exceptions as crypto_exception
from cursive import exception as cursive_exception
from cursive import signature_utils
import glance_store as store
from oslo_config import cfg
from oslo_log import log as logging
from oslo_utils import encodeutils
from oslo_utils import excutils

from glance.common import exception
from glance.common import format_inspector
from glance.common import store_utils
from glance.common import utils
import glance.domain.proxy
from glance.i18n import _, _LE, _LI, _LW


CONF = cfg.CONF
LOG = logging.getLogger(__name__)


class ImageRepoProxy(glance.domain.proxy.Repo):

    def __init__(self, image_repo, context, store_api, store_utils):
        self.context = context
        self.store_api = store_api
        self.image_repo = image_repo
        proxy_kwargs = {'context': context, 'store_api': store_api,
                        'store_utils': store_utils}
        super(ImageRepoProxy, self).__init__(image_repo,
                                             item_proxy_class=ImageProxy,
                                             item_proxy_kwargs=proxy_kwargs)

        self.db_api = glance.db.get_api()

    def _set_acls(self, image):
        public = image.visibility in ['public', 'community']
        member_ids = []
        if image.locations and not public:
            member_repo = _get_member_repo_for_store(image,
                                                     self.context,
                                                     self.db_api,
                                                     self.store_api)
            member_ids = [m.member_id for m in member_repo.list()]
        for location in image.locations:
            if CONF.enabled_backends:
                # NOTE(whoami-rajat): Do not set_acls if store is not defined
                # on this node. This is possible in case of edge deployment
                # that image location is present but the actual store is
                # not related to this node.
                image_store = location['metadata'].get('store')
                if image_store not in CONF.enabled_backends:
                    msg = (_("Store %s is not available on "
                             "this node, skipping `_set_acls` "
                             "call.") % image_store)
                    LOG.debug(msg)
                    continue
                self.store_api.set_acls_for_multi_store(
                    location['url'], image_store,
                    public=public, read_tenants=member_ids,
                    context=self.context
                )
            else:
                self.store_api.set_acls(location['url'], public=public,
                                        read_tenants=member_ids,
                                        context=self.context)

    def add(self, image):
        result = super(ImageRepoProxy, self).add(image)
        self._set_acls(image)
        return result

    def save(self, image, from_state=None):
        result = super(ImageRepoProxy, self).save(image, from_state=from_state)
        self._set_acls(image)
        return result

    def get(self, image_id):
        image = super(ImageRepoProxy, self).get(image_id)
        if CONF.enabled_backends:
            try:
                store_utils.update_store_in_locations(
                    self.context, image, self.image_repo)
            except exception.Forbidden:
                # NOTE(danms): We may not be able to complete a store
                # update if we do not own the image. That should not
                # break us, so avoid raising Forbidden in that
                # case. Note that modifications to @image here will
                # still be returned to the user, just not saved in the
                # DB. That is probably what we want anyway.
                pass
        return image


def _get_member_repo_for_store(image, context, db_api, store_api):
    image_member_repo = glance.db.ImageMemberRepo(context, db_api, image)
    store_image_repo = glance.location.ImageMemberRepoProxy(
        image_member_repo, image, context, store_api)

    return store_image_repo


def _check_location_uri(context, store_api, store_utils, uri,
                        backend=None):
    """Check if an image location is valid.

    :param context: Glance request context
    :param store_api: store API module
    :param store_utils: store utils module
    :param uri: location's uri string
    :param backend: A backend name for the store
    """

    try:
        # NOTE(zhiyan): Some stores return zero when it catch exception
        if CONF.enabled_backends:
            size_from_backend = store_api.get_size_from_uri_and_backend(
                uri, backend, context=context)
        else:
            size_from_backend = store_api.get_size_from_backend(
                uri, context=context)

        is_ok = (store_utils.validate_external_location(uri) and
                 size_from_backend > 0)
    except (store.UnknownScheme, store.NotFound, store.BadStoreUri):
        is_ok = False
    if not is_ok:
        reason = _('Invalid location')
        raise exception.BadStoreUri(message=reason)


def _check_image_location(context, store_api, store_utils, location):
    backend = None
    if CONF.enabled_backends:
        backend = location['metadata'].get('store')

    _check_location_uri(context, store_api, store_utils, location['url'],
                        backend=backend)
    store_api.check_location_metadata(location['metadata'])


def _set_image_size(context, image, locations):
    if not image.size:
        for location in locations:
            if CONF.enabled_backends:
                size_from_backend = store.get_size_from_uri_and_backend(
                    location['url'], location['metadata'].get('store'),
                    context=context)
            else:
                size_from_backend = store.get_size_from_backend(
                    location['url'], context=context)

            if size_from_backend:
                # NOTE(flwang): This assumes all locations have the same size
                image.size = size_from_backend
                break


def _count_duplicated_locations(locations, new):
    """
    To calculate the count of duplicated locations for new one.

    :param locations: The exiting image location set
    :param new: The new image location
    :returns: The count of duplicated locations
    """

    ret = 0
    for loc in locations:
        if loc['url'] == new['url'] and loc['metadata'] == new['metadata']:
            ret += 1
    return ret


class ImageFactoryProxy(glance.domain.proxy.ImageFactory):
    def __init__(self, factory, context, store_api, store_utils):
        self.context = context
        self.store_api = store_api
        self.store_utils = store_utils
        proxy_kwargs = {'context': context, 'store_api': store_api,
                        'store_utils': store_utils}
        super(ImageFactoryProxy, self).__init__(factory,
                                                proxy_class=ImageProxy,
                                                proxy_kwargs=proxy_kwargs)

    def new_image(self, **kwargs):
        locations = kwargs.get('locations', [])
        for loc in locations:
            _check_image_location(self.context,
                                  self.store_api,
                                  self.store_utils,
                                  loc)
            loc['status'] = 'active'
            if _count_duplicated_locations(locations, loc) > 1:
                raise exception.DuplicateLocation(location=loc['url'])
        return super(ImageFactoryProxy, self).new_image(**kwargs)


@functools.total_ordering
class StoreLocations(abc.MutableSequence):
    """
    The proxy for store location property. It takes responsibility for::

        1. Location uri correctness checking when adding a new location.
        2. Remove the image data from the store when a location is removed
           from an image.

    """
    def __init__(self, image_proxy, value):
        self.image_proxy = image_proxy
        if isinstance(value, list):
            self.value = value
        else:
            self.value = list(value)

    def append(self, location):
        # NOTE(flaper87): Insert this
        # location at the very end of
        # the value list.
        self.insert(len(self.value), location)

    def extend(self, other):
        if isinstance(other, StoreLocations):
            locations = other.value
        else:
            locations = list(other)

        for location in locations:
            self.append(location)

    def insert(self, i, location):
        _check_image_location(self.image_proxy.context,
                              self.image_proxy.store_api,
                              self.image_proxy.store_utils,
                              location)
        location['status'] = 'active'
        if _count_duplicated_locations(self.value, location) > 0:
            raise exception.DuplicateLocation(location=location['url'])

        self.value.insert(i, location)
        _set_image_size(self.image_proxy.context,
                        self.image_proxy,
                        [location])

    def pop(self, i=-1):
        location = self.value.pop(i)
        try:
            self.image_proxy.store_utils.delete_image_location_from_backend(
                self.image_proxy.context,
                self.image_proxy.image.image_id,
                location)
        except store.exceptions.NotFound:
            # NOTE(rosmaita): This can happen if the data was deleted by an
            # operator from the backend, or a race condition from multiple
            # delete-from-store requests. The old way to deal with this was
            # that the user could just delete the image when the data is gone,
            # but with multi-store, that is no longer a good option. So we
            # intentionally leave the location popped (in other words, the
            # pop() succeeds) but we also reraise the NotFound so that the
            # calling code knows what happened.
            with excutils.save_and_reraise_exception():
                pass
        except Exception:
            with excutils.save_and_reraise_exception():
                self.value.insert(i, location)
        return location

    def count(self, location):
        return self.value.count(location)

    def index(self, location, *args):
        return self.value.index(location, *args)

    def remove(self, location):
        if self.count(location):
            self.pop(self.index(location))
        else:
            self.value.remove(location)

    def reverse(self):
        self.value.reverse()

    # Mutable sequence, so not hashable
    __hash__ = None

    def __getitem__(self, i):
        return self.value.__getitem__(i)

    def __setitem__(self, i, location):
        _check_image_location(self.image_proxy.context,
                              self.image_proxy.store_api,
                              self.image_proxy.store_utils,
                              location)
        location['status'] = 'active'
        self.value.__setitem__(i, location)
        _set_image_size(self.image_proxy.context,
                        self.image_proxy,
                        [location])

    def __delitem__(self, i):
        if isinstance(i, slice):
            if i.step not in (None, 1):
                raise NotImplementedError("slice with step")
            self.__delslice__(i.start, i.stop)
            return
        location = None
        try:
            location = self.value[i]
        except Exception:
            del self.value[i]
            return
        self.image_proxy.store_utils.delete_image_location_from_backend(
            self.image_proxy.context,
            self.image_proxy.image.image_id,
            location)
        del self.value[i]

    def __delslice__(self, i, j):
        i = 0 if i is None else max(i, 0)
        j = len(self) if j is None else max(j, 0)
        locations = []
        try:
            locations = self.value[i:j]
        except Exception:
            del self.value[i:j]
            return
        for location in locations:
            self.image_proxy.store_utils.delete_image_location_from_backend(
                self.image_proxy.context,
                self.image_proxy.image.image_id,
                location)
            del self.value[i]

    def __iadd__(self, other):
        self.extend(other)
        return self

    def __contains__(self, location):
        return location in self.value

    def __len__(self):
        return len(self.value)

    def __cast(self, other):
        if isinstance(other, StoreLocations):
            return other.value
        else:
            return other

    def __eq__(self, other):
        return self.value == self.__cast(other)

    def __lt__(self, other):
        return self.value < self.__cast(other)

    def __iter__(self):
        return iter(self.value)

    def __copy__(self):
        return type(self)(self.image_proxy, self.value)

    def __deepcopy__(self, memo):
        # NOTE(zhiyan): Only copy location entries, others can be reused.
        value = copy.deepcopy(self.value, memo)
        self.image_proxy.image.locations = value
        return type(self)(self.image_proxy, value)


def _locations_proxy(target, attr):
    """
    Make a location property proxy on the image object.

    :param target: the image object on which to add the proxy
    :param attr: the property proxy we want to hook
    """
    def get_attr(self):
        value = getattr(getattr(self, target), attr)
        return StoreLocations(self, value)

    def set_attr(self, value):
        if not isinstance(value, (list, StoreLocations)):
            reason = _('Invalid locations')
            raise exception.BadStoreUri(message=reason)
        ori_value = getattr(getattr(self, target), attr)
        if ori_value != value:
            # NOTE(flwang): If all the URL of passed-in locations are same as
            # current image locations, that means user would like to only
            # update the metadata, not the URL.
            ordered_value = sorted([loc['url'] for loc in value])
            ordered_ori = sorted([loc['url'] for loc in ori_value])
            if len(ori_value) > 0 and ordered_value != ordered_ori:
                raise exception.Invalid(_('Original locations is not empty: '
                                          '%s') % ori_value)
            # NOTE(zhiyan): Check locations are all valid
            # NOTE(flwang): If all the URL of passed-in locations are same as
            # current image locations, then it's not necessary to verify those
            # locations again. Otherwise, if there is any restricted scheme in
            # existing locations. _check_image_location will fail.
            if ordered_value != ordered_ori:
                for loc in value:
                    _check_image_location(self.context,
                                          self.store_api,
                                          self.store_utils,
                                          loc)
                    loc['status'] = 'active'
                    if _count_duplicated_locations(value, loc) > 1:
                        raise exception.DuplicateLocation(location=loc['url'])
                _set_image_size(self.context, getattr(self, target), value)
            else:
                for loc in value:
                    loc['status'] = 'active'
            return setattr(getattr(self, target), attr, list(value))

    def del_attr(self):
        value = getattr(getattr(self, target), attr)
        while len(value):
            self.store_utils.delete_image_location_from_backend(
                self.context,
                self.image.image_id,
                value[0])
            del value[0]
            setattr(getattr(self, target), attr, value)
        return delattr(getattr(self, target), attr)

    return property(get_attr, set_attr, del_attr)


class ImageProxy(glance.domain.proxy.Image):

    locations = _locations_proxy('image', 'locations')

    def __init__(self, image, context, store_api, store_utils):
        self.image = image
        self.context = context
        self.store_api = store_api
        self.store_utils = store_utils
        proxy_kwargs = {
            'context': context,
            'image': self,
            'store_api': store_api,
        }
        super(ImageProxy, self).__init__(
            image, member_repo_proxy_class=ImageMemberRepoProxy,
            member_repo_proxy_kwargs=proxy_kwargs)

    def delete(self):
        self.image.delete()
        if self.image.locations:
            for location in self.image.locations:
                self.store_utils.delete_image_location_from_backend(
                    self.context,
                    self.image.image_id,
                    location)

    def _upload_to_store(self, data, verifier, store=None, size=None):
        """
        Upload data to store

        :param data: data to upload to store
        :param verifier: for signature verification
        :param store: store to upload data to
        :param size: data size
        :return:
        """
        hashing_algo = self.image.os_hash_algo or CONF['hashing_algorithm']
        if CONF.enabled_backends:
            (location, size, checksum,
             multihash, loc_meta) = self.store_api.add_with_multihash(
                CONF,
                self.image.image_id,
                utils.LimitingReader(utils.CooperativeReader(data),
                                     CONF.image_size_cap),
                size,
                store,
                hashing_algo,
                context=self.context,
                verifier=verifier)
        else:
            (location,
             size,
             checksum,
             multihash,
             loc_meta) = self.store_api.add_to_backend_with_multihash(
                CONF,
                self.image.image_id,
                utils.LimitingReader(utils.CooperativeReader(data),
                                     CONF.image_size_cap),
                size,
                hashing_algo,
                context=self.context,
                verifier=verifier)
        self._verify_signature(verifier, location, loc_meta)
        for attr, data in {"size": size, "os_hash_value": multihash,
                           "checksum": checksum}.items():
            self._verify_uploaded_data(data, attr)
        self.image.locations.append({'url': location, 'metadata': loc_meta,
                                     'status': 'active'})
        self.image.checksum = checksum
        self.image.os_hash_value = multihash
        self.image.size = size
        self.image.os_hash_algo = hashing_algo

    def _verify_signature(self, verifier, location, loc_meta):
        """
        Verify signature of uploaded data.

        :param verifier: for signature verification
        """
        # NOTE(bpoulos): if verification fails, exception will be raised
        if verifier is not None:
            try:
                verifier.verify()
                msg = _LI("Successfully verified signature for image %s")
                LOG.info(msg, self.image.image_id)
            except crypto_exception.InvalidSignature:
                if CONF.enabled_backends:
                    self.store_api.delete(location,
                                          loc_meta.get('store'),
                                          context=self.context)
                else:
                    self.store_api.delete_from_backend(location,
                                                       context=self.context)
                raise cursive_exception.SignatureVerificationError(
                    _('Signature verification failed')
                )

    def _verify_uploaded_data(self, value, attribute_name):
        """
        Verify value of attribute_name uploaded data

        :param value: value to compare
        :param attribute_name: attribute name of the image to compare with
        """
        image_value = getattr(self.image, attribute_name)
        if image_value is not None and value != image_value:
            msg = _("%s of uploaded data is different from current "
                    "value set on the image.")
            LOG.error(msg, attribute_name)
            raise exception.UploadException(msg % attribute_name)

    def set_data(self, data, size=None, backend=None, set_active=True):
        if size is None:
            size = 0  # NOTE(markwash): zero -> unknown size

        # Create the verifier for signature verification (if correct properties
        # are present)
        extra_props = self.image.extra_properties
        verifier = None
        if signature_utils.should_create_verifier(extra_props):
            # NOTE(bpoulos): if creating verifier fails, exception will be
            # raised
            img_signature = extra_props[signature_utils.SIGNATURE]
            hash_method = extra_props[signature_utils.HASH_METHOD]
            key_type = extra_props[signature_utils.KEY_TYPE]
            cert_uuid = extra_props[signature_utils.CERT_UUID]
            verifier = signature_utils.get_verifier(
                context=self.context,
                img_signature_certificate_uuid=cert_uuid,
                img_signature_hash_method=hash_method,
                img_signature=img_signature,
                img_signature_key_type=key_type
            )

        if not self.image.virtual_size:
            inspector = format_inspector.get_inspector(self.image.disk_format)
        else:
            # No need to do this again
            inspector = None

        if inspector and self.image.container_format == 'bare':
            fmt = inspector()
            data = format_inspector.InfoWrapper(data, fmt)
            LOG.debug('Enabling in-flight format inspection for %s', fmt)
        else:
            fmt = None

        self._upload_to_store(data, verifier, backend, size)

        virtual_size = 0
        if fmt and fmt.format_match:
            try:
                virtual_size = fmt.virtual_size
                LOG.info('Image format matched and virtual size computed: %i',
                         virtual_size)
            except Exception as e:
                LOG.error(_LE('Unable to determine virtual_size because: %s'),
                          e)
        elif fmt:
            LOG.warning('Image format %s did not match; '
                        'unable to calculate virtual size',
                        self.image.disk_format)

        if virtual_size:
            self.image.virtual_size = fmt.virtual_size

        if set_active and self.image.status != 'active':
            self.image.status = 'active'

    def get_data(self, offset=0, chunk_size=None):
        if not self.image.locations:
            # NOTE(mclaren): This is the only set of arguments
            # which work with this exception currently, see:
            # https://bugs.launchpad.net/glance-store/+bug/1501443
            # When the above glance_store bug is fixed we can
            # add a msg as usual.
            raise store.NotFound(image=None)
        err = None
        for loc in self.image.locations:
            try:
                backend = loc['metadata'].get('store')
                if CONF.enabled_backends:
                    data, size = self.store_api.get(
                        loc['url'], backend, offset=offset,
                        chunk_size=chunk_size, context=self.context
                    )
                else:
                    data, size = self.store_api.get_from_backend(
                        loc['url'],
                        offset=offset,
                        chunk_size=chunk_size,
                        context=self.context)

                return data
            except Exception as e:
                LOG.warning(_LW('Get image %(id)s data failed: '
                                '%(err)s.'),
                            {'id': self.image.image_id,
                            'err': encodeutils.exception_to_unicode(e)})
                err = e
        # tried all locations
        LOG.error(_LE('Glance tried all active locations to get data for '
                      'image %s but all have failed.'), self.image.image_id)
        raise err


class ImageMemberRepoProxy(glance.domain.proxy.Repo):
    def __init__(self, repo, image, context, store_api):
        self.repo = repo
        self.image = image
        self.context = context
        self.store_api = store_api
        super(ImageMemberRepoProxy, self).__init__(repo)

    def _set_acls(self):
        public = self.image.visibility in ['public', 'community']
        if self.image.locations and not public:
            member_ids = [m.member_id for m in self.repo.list()]
            for location in self.image.locations:
                if CONF.enabled_backends:
                    # NOTE(whoami-rajat): Do not set_acls if store is not
                    # defined on this node. This is possible in case of edge
                    # deployment that image location is present but the actual
                    # store is not related to this node.
                    image_store = location['metadata'].get('store')
                    if image_store not in CONF.enabled_backends:
                        msg = (_("Store %s is not available on "
                                 "this node, skipping `_set_acls` "
                                 "call.") % image_store)
                        LOG.debug(msg)
                        continue
                    self.store_api.set_acls_for_multi_store(
                        location['url'], image_store,
                        public=public, read_tenants=member_ids,
                        context=self.context
                    )
                else:
                    self.store_api.set_acls(location['url'], public=public,
                                            read_tenants=member_ids,
                                            context=self.context)

    def add(self, member):
        super(ImageMemberRepoProxy, self).add(member)
        self._set_acls()

    def remove(self, member):
        super(ImageMemberRepoProxy, self).remove(member)
        self._set_acls()
