# Copyright 2010-2011 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.

"""
Reference implementation registry server WSGI controller
"""

from oslo_config import cfg
from oslo_log import log as logging
from oslo_utils import encodeutils
from oslo_utils import strutils
from oslo_utils import uuidutils
from webob import exc

from glance.common import exception
from glance.common import timeutils
from glance.common import utils
from glance.common import wsgi
import glance.db
from glance.i18n import _, _LE, _LI, _LW


LOG = logging.getLogger(__name__)

CONF = cfg.CONF

DISPLAY_FIELDS_IN_INDEX = ['id', 'name', 'size',
                           'disk_format', 'container_format',
                           'checksum']

SUPPORTED_FILTERS = ['name', 'status', 'container_format', 'disk_format',
                     'min_ram', 'min_disk', 'size_min', 'size_max',
                     'changes-since', 'protected']

SUPPORTED_SORT_KEYS = ('name', 'status', 'container_format', 'disk_format',
                       'size', 'id', 'created_at', 'updated_at')

SUPPORTED_SORT_DIRS = ('asc', 'desc')

SUPPORTED_PARAMS = ('limit', 'marker', 'sort_key', 'sort_dir')


def _normalize_image_location_for_db(image_data):
    """
    This function takes the legacy locations field and the newly added
    location_data field from the image_data values dictionary which flows
    over the wire between the registry and API servers and converts it
    into the location_data format only which is then consumable by the
    Image object.

    :param image_data: a dict of values representing information in the image
    :returns: a new image data dict
    """
    if 'locations' not in image_data and 'location_data' not in image_data:
        image_data['locations'] = None
        return image_data

    locations = image_data.pop('locations', [])
    location_data = image_data.pop('location_data', [])

    location_data_dict = {}
    for l in locations:
        location_data_dict[l] = {}
    for l in location_data:
        location_data_dict[l['url']] = {'metadata': l['metadata'],
                                        'status': l['status'],
                                        # Note(zhiyan): New location has no ID.
                                        'id': l['id'] if 'id' in l else None}

    # NOTE(jbresnah) preserve original order.  tests assume original order,
    # should that be defined functionality
    ordered_keys = locations[:]
    for ld in location_data:
        if ld['url'] not in ordered_keys:
            ordered_keys.append(ld['url'])

    location_data = []
    for loc in ordered_keys:
        data = location_data_dict[loc]
        if data:
            location_data.append({'url': loc,
                                  'metadata': data['metadata'],
                                  'status': data['status'],
                                  'id': data['id']})
        else:
            location_data.append({'url': loc,
                                  'metadata': {},
                                  'status': 'active',
                                  'id': None})

    image_data['locations'] = location_data
    return image_data


class Controller(object):

    def __init__(self):
        self.db_api = glance.db.get_api()

    def _get_images(self, context, filters, **params):
        """Get images, wrapping in exception if necessary."""
        # NOTE(markwash): for backwards compatibility, is_public=True for
        # admins actually means "treat me as if I'm not an admin and show me
        # all my images"
        if context.is_admin and params.get('is_public') is True:
            params['admin_as_user'] = True
            del params['is_public']
        try:
            return self.db_api.image_get_all(context, filters=filters,
                                             **params)
        except exception.ImageNotFound:
            LOG.warn(_LW("Invalid marker. Image %(id)s could not be "
                         "found.") % {'id': params.get('marker')})
            msg = _("Invalid marker. Image could not be found.")
            raise exc.HTTPBadRequest(explanation=msg)
        except exception.Forbidden:
            LOG.warn(_LW("Access denied to image %(id)s but returning "
                         "'not found'") % {'id': params.get('marker')})
            msg = _("Invalid marker. Image could not be found.")
            raise exc.HTTPBadRequest(explanation=msg)
        except Exception:
            LOG.exception(_LE("Unable to get images"))
            raise

    def index(self, req):
        """Return a basic filtered list of public, non-deleted images

        :param req: the Request object coming from the wsgi layer
        :returns: a mapping of the following form

        .. code-block:: python

            dict(images=[image_list])

        Where image_list is a sequence of mappings

        .. code-block:: json

            {
                'id': <ID>,
                'name': <NAME>,
                'size': <SIZE>,
                'disk_format': <DISK_FORMAT>,
                'container_format': <CONTAINER_FORMAT>,
                'checksum': <CHECKSUM>
            }

        """
        params = self._get_query_params(req)
        images = self._get_images(req.context, **params)

        results = []
        for image in images:
            result = {}
            for field in DISPLAY_FIELDS_IN_INDEX:
                result[field] = image[field]
            results.append(result)

        LOG.debug("Returning image list")
        return dict(images=results)

    def detail(self, req):
        """Return a filtered list of public, non-deleted images in detail

        :param req: the Request object coming from the wsgi layer
        :returns: a mapping of the following form

        .. code-block:: json

            {'images':
                [{
                    'id': <ID>,
                    'name': <NAME>,
                    'size': <SIZE>,
                    'disk_format': <DISK_FORMAT>,
                    'container_format': <CONTAINER_FORMAT>,
                    'checksum': <CHECKSUM>,
                    'min_disk': <MIN_DISK>,
                    'min_ram': <MIN_RAM>,
                    'store': <STORE>,
                    'status': <STATUS>,
                    'created_at': <TIMESTAMP>,
                    'updated_at': <TIMESTAMP>,
                    'deleted_at': <TIMESTAMP>|<NONE>,
                    'properties': {'distro': 'Ubuntu 10.04 LTS', {...}}
                }, {...}]
            }

        """
        params = self._get_query_params(req)

        images = self._get_images(req.context, **params)
        image_dicts = [make_image_dict(i) for i in images]
        LOG.debug("Returning detailed image list")
        return dict(images=image_dicts)

    def _get_query_params(self, req):
        """Extract necessary query parameters from http request.

        :param req: the Request object coming from the wsgi layer
        :returns: dictionary of filters to apply to list of images
        """
        params = {
            'filters': self._get_filters(req),
            'limit': self._get_limit(req),
            'sort_key': [self._get_sort_key(req)],
            'sort_dir': [self._get_sort_dir(req)],
            'marker': self._get_marker(req),
        }

        if req.context.is_admin:
            # Only admin gets to look for non-public images
            params['is_public'] = self._get_is_public(req)

        # need to coy items because the params is modified in the loop body
        items = list(params.items())
        for key, value in items:
            if value is None:
                del params[key]

        # Fix for LP Bug #1132294
        # Ensure all shared images are returned in v1
        params['member_status'] = 'all'
        return params

    def _get_filters(self, req):
        """Return a dictionary of query param filters from the request

        :param req: the Request object coming from the wsgi layer
        :returns: a dict of key/value filters
        """
        filters = {}
        properties = {}

        for param in req.params:
            if param in SUPPORTED_FILTERS:
                filters[param] = req.params.get(param)
            if param.startswith('property-'):
                _param = param[9:]
                properties[_param] = req.params.get(param)

        if 'changes-since' in filters:
            isotime = filters['changes-since']
            try:
                filters['changes-since'] = timeutils.parse_isotime(isotime)
            except ValueError:
                raise exc.HTTPBadRequest(_("Unrecognized changes-since value"))

        if 'protected' in filters:
            value = self._get_bool(filters['protected'])
            if value is None:
                raise exc.HTTPBadRequest(_("protected must be True, or "
                                           "False"))

            filters['protected'] = value

        # only allow admins to filter on 'deleted'
        if req.context.is_admin:
            deleted_filter = self._parse_deleted_filter(req)
            if deleted_filter is not None:
                filters['deleted'] = deleted_filter
            elif 'changes-since' not in filters:
                filters['deleted'] = False
        elif 'changes-since' not in filters:
            filters['deleted'] = False

        if properties:
            filters['properties'] = properties

        return filters

    def _get_limit(self, req):
        """Parse a limit query param into something usable."""
        try:
            limit = int(req.params.get('limit', CONF.limit_param_default))
        except ValueError:
            raise exc.HTTPBadRequest(_("limit param must be an integer"))

        if limit < 0:
            raise exc.HTTPBadRequest(_("limit param must be positive"))

        return min(CONF.api_limit_max, limit)

    def _get_marker(self, req):
        """Parse a marker query param into something usable."""
        marker = req.params.get('marker', None)

        if marker and not uuidutils.is_uuid_like(marker):
            msg = _('Invalid marker format')
            raise exc.HTTPBadRequest(explanation=msg)

        return marker

    def _get_sort_key(self, req):
        """Parse a sort key query param from the request object."""
        sort_key = req.params.get('sort_key', 'created_at')
        if sort_key is not None and sort_key not in SUPPORTED_SORT_KEYS:
            _keys = ', '.join(SUPPORTED_SORT_KEYS)
            msg = _("Unsupported sort_key. Acceptable values: %s") % (_keys,)
            raise exc.HTTPBadRequest(explanation=msg)
        return sort_key

    def _get_sort_dir(self, req):
        """Parse a sort direction query param from the request object."""
        sort_dir = req.params.get('sort_dir', 'desc')
        if sort_dir is not None and sort_dir not in SUPPORTED_SORT_DIRS:
            _keys = ', '.join(SUPPORTED_SORT_DIRS)
            msg = _("Unsupported sort_dir. Acceptable values: %s") % (_keys,)
            raise exc.HTTPBadRequest(explanation=msg)
        return sort_dir

    def _get_bool(self, value):
        value = value.lower()
        if value == 'true' or value == '1':
            return True
        elif value == 'false' or value == '0':
            return False

        return None

    def _get_is_public(self, req):
        """Parse is_public into something usable."""
        is_public = req.params.get('is_public', None)

        if is_public is None:
            # NOTE(vish): This preserves the default value of showing only
            #             public images.
            return True
        elif is_public.lower() == 'none':
            return None

        value = self._get_bool(is_public)
        if value is None:
            raise exc.HTTPBadRequest(_("is_public must be None, True, or "
                                       "False"))

        return value

    def _parse_deleted_filter(self, req):
        """Parse deleted into something usable."""
        deleted = req.params.get('deleted')
        if deleted is None:
            return None
        return strutils.bool_from_string(deleted)

    def show(self, req, id):
        """Return data about the given image id."""
        try:
            image = self.db_api.image_get(req.context, id)
            LOG.debug("Successfully retrieved image %(id)s", {'id': id})
        except exception.ImageNotFound:
            LOG.info(_LI("Image %(id)s not found"), {'id': id})
            raise exc.HTTPNotFound()
        except exception.Forbidden:
            # If it's private and doesn't belong to them, don't let on
            # that it exists
            LOG.info(_LI("Access denied to image %(id)s but returning"
                         " 'not found'"), {'id': id})
            raise exc.HTTPNotFound()
        except Exception:
            LOG.exception(_LE("Unable to show image %s") % id)
            raise

        return dict(image=make_image_dict(image))

    @utils.mutating
    def delete(self, req, id):
        """Deletes an existing image with the registry.

        :param req: wsgi Request object
        :param id:  The opaque internal identifier for the image

        :returns: 200 if delete was successful, a fault if not. On
            success, the body contains the deleted image
            information as a mapping.
        """
        try:
            deleted_image = self.db_api.image_destroy(req.context, id)
            LOG.info(_LI("Successfully deleted image %(id)s"), {'id': id})
            return dict(image=make_image_dict(deleted_image))
        except exception.ForbiddenPublicImage:
            LOG.info(_LI("Delete denied for public image %(id)s"), {'id': id})
            raise exc.HTTPForbidden()
        except exception.Forbidden:
            # If it's private and doesn't belong to them, don't let on
            # that it exists
            LOG.info(_LI("Access denied to image %(id)s but returning"
                         " 'not found'"), {'id': id})
            return exc.HTTPNotFound()
        except exception.ImageNotFound:
            LOG.info(_LI("Image %(id)s not found"), {'id': id})
            return exc.HTTPNotFound()
        except Exception:
            LOG.exception(_LE("Unable to delete image %s") % id)
            raise

    @utils.mutating
    def create(self, req, body):
        """Registers a new image with the registry.

        :param req: wsgi Request object
        :param body: Dictionary of information about the image

        :returns: The newly-created image information as a mapping,
            which will include the newly-created image's internal id
            in the 'id' field
        """
        image_data = body['image']

        # Ensure the image has a status set
        image_data.setdefault('status', 'active')

        # Set up the image owner
        if not req.context.is_admin or 'owner' not in image_data:
            image_data['owner'] = req.context.owner

        image_id = image_data.get('id')
        if image_id and not uuidutils.is_uuid_like(image_id):
            LOG.info(_LI("Rejecting image creation request for invalid image "
                         "id '%(bad_id)s'"), {'bad_id': image_id})
            msg = _("Invalid image id format")
            return exc.HTTPBadRequest(explanation=msg)

        if 'location' in image_data:
            image_data['locations'] = [image_data.pop('location')]

        try:
            image_data = _normalize_image_location_for_db(image_data)
            image_data = self.db_api.image_create(req.context, image_data)
            image_data = dict(image=make_image_dict(image_data))
            LOG.info(_LI("Successfully created image %(id)s"),
                     {'id': image_data['image']['id']})
            return image_data
        except exception.Duplicate:
            msg = _("Image with identifier %s already exists!") % image_id
            LOG.warn(msg)
            return exc.HTTPConflict(msg)
        except exception.Invalid as e:
            msg = (_("Failed to add image metadata. "
                     "Got error: %s") % encodeutils.exception_to_unicode(e))
            LOG.error(msg)
            return exc.HTTPBadRequest(msg)
        except Exception:
            LOG.exception(_LE("Unable to create image %s"), image_id)
            raise

    @utils.mutating
    def update(self, req, id, body):
        """Updates an existing image with the registry.

        :param req: wsgi Request object
        :param body: Dictionary of information about the image
        :param id:  The opaque internal identifier for the image

        :returns: Returns the updated image information as a mapping,
        """
        image_data = body['image']
        from_state = body.get('from_state', None)

        # Prohibit modification of 'owner'
        if not req.context.is_admin and 'owner' in image_data:
            del image_data['owner']

        if 'location' in image_data:
            image_data['locations'] = [image_data.pop('location')]

        purge_props = req.headers.get("X-Glance-Registry-Purge-Props", "false")
        try:
            # These fields hold sensitive data, which should not be printed in
            # the logs.
            sensitive_fields = ['locations', 'location_data']
            LOG.debug("Updating image %(id)s with metadata: %(image_data)r",
                      {'id': id,
                       'image_data': {k: v for k, v in image_data.items()
                                      if k not in sensitive_fields}})
            image_data = _normalize_image_location_for_db(image_data)
            if purge_props == "true":
                purge_props = True
            else:
                purge_props = False

            updated_image = self.db_api.image_update(req.context, id,
                                                     image_data,
                                                     purge_props=purge_props,
                                                     from_state=from_state)

            LOG.info(_LI("Updating metadata for image %(id)s"), {'id': id})
            return dict(image=make_image_dict(updated_image))
        except exception.Invalid as e:
            msg = (_("Failed to update image metadata. "
                     "Got error: %s") % encodeutils.exception_to_unicode(e))
            LOG.error(msg)
            return exc.HTTPBadRequest(msg)
        except exception.ImageNotFound:
            LOG.info(_LI("Image %(id)s not found"), {'id': id})
            raise exc.HTTPNotFound(body='Image not found',
                                   request=req,
                                   content_type='text/plain')
        except exception.ForbiddenPublicImage:
            LOG.info(_LI("Update denied for public image %(id)s"), {'id': id})
            raise exc.HTTPForbidden()
        except exception.Forbidden:
            # If it's private and doesn't belong to them, don't let on
            # that it exists
            LOG.info(_LI("Access denied to image %(id)s but returning"
                         " 'not found'"), {'id': id})
            raise exc.HTTPNotFound(body='Image not found',
                                   request=req,
                                   content_type='text/plain')
        except exception.Conflict as e:
            LOG.info(encodeutils.exception_to_unicode(e))
            raise exc.HTTPConflict(body='Image operation conflicts',
                                   request=req,
                                   content_type='text/plain')
        except Exception:
            LOG.exception(_LE("Unable to update image %s") % id)
            raise


def _limit_locations(image):
    locations = image.pop('locations', [])
    image['location_data'] = locations
    image['location'] = None
    for loc in locations:
        if loc['status'] == 'active':
            image['location'] = loc['url']
            break


def make_image_dict(image):
    """Create a dict representation of an image which we can use to
    serialize the image.
    """

    def _fetch_attrs(d, attrs):
        return {a: d[a] for a in attrs if a in d.keys()}

    # TODO(sirp): should this be a dict, or a list of dicts?
    # A plain dict is more convenient, but list of dicts would provide
    # access to created_at, etc
    properties = {p['name']: p['value'] for p in image['properties']
                  if not p['deleted']}

    image_dict = _fetch_attrs(image, glance.db.IMAGE_ATTRS)
    image_dict['properties'] = properties
    _limit_locations(image_dict)

    return image_dict


def create_resource():
    """Images resource factory method."""
    deserializer = wsgi.JSONRequestDeserializer()
    serializer = wsgi.JSONResponseSerializer()
    return wsgi.Resource(Controller(), deserializer, serializer)
