#    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.
"""Inventory handlers for Placement API."""

import copy
import operator

from oslo_db import exception as db_exc
from oslo_serialization import jsonutils
from oslo_utils import encodeutils
import webob

from placement.db import constants as db_const
from placement import errors
from placement import exception
from placement import microversion
from placement.objects import inventory as inv_obj
from placement.objects import resource_provider as rp_obj
from placement.policies import inventory as policies
from placement.schemas import inventory as schema
from placement import util
from placement import wsgi_wrapper


# NOTE(cdent): We keep our own representation of inventory defaults
# and output fields, separate from the versioned object to avoid
# inadvertent API changes when the object defaults are changed.
OUTPUT_INVENTORY_FIELDS = [
    'total',
    'reserved',
    'min_unit',
    'max_unit',
    'step_size',
    'allocation_ratio',
]
INVENTORY_DEFAULTS = {
    'reserved': 0,
    'min_unit': 1,
    'max_unit': db_const.MAX_INT,
    'step_size': 1,
    'allocation_ratio': 1.0
}


def _extract_inventory(body, schema):
    """Extract and validate inventory from JSON body."""
    data = util.extract_json(body, schema)

    inventory_data = copy.copy(INVENTORY_DEFAULTS)
    inventory_data.update(data)

    return inventory_data


def _extract_inventories(body, schema):
    """Extract and validate multiple inventories from JSON body."""
    data = util.extract_json(body, schema)

    inventories = {}
    for res_class, raw_inventory in data['inventories'].items():
        inventory_data = copy.copy(INVENTORY_DEFAULTS)
        inventory_data.update(raw_inventory)
        inventories[res_class] = inventory_data

    data['inventories'] = inventories
    return data


def make_inventory_object(resource_provider, resource_class, **data):
    """Single place to catch malformed Inventories."""
    # TODO(cdent): Some of the validation checks that are done here
    # could be done via JSONschema (using, for example, "minimum":
    # 0) for non-negative integers. It's not clear if that is
    # duplication or decoupling so leaving it as this for now.
    try:
        inventory = inv_obj.Inventory(
            resource_provider=resource_provider,
            resource_class=resource_class, **data)
    except (ValueError, TypeError) as exc:
        raise webob.exc.HTTPBadRequest(
            'Bad inventory %(class)s for resource provider '
            '%(rp_uuid)s: %(error)s' % {'class': resource_class,
                                        'rp_uuid': resource_provider.uuid,
                                        'error': exc})
    return inventory


def _send_inventories(req, resource_provider, inventories):
    """Send a JSON representation of a list of inventories."""
    response = req.response
    response.status = 200
    output, last_modified = _serialize_inventories(
        inventories, resource_provider.generation)
    response.body = encodeutils.to_utf8(jsonutils.dumps(output))
    response.content_type = 'application/json'
    want_version = req.environ[microversion.MICROVERSION_ENVIRON]
    if want_version.matches((1, 15)):
        response.last_modified = last_modified
        response.cache_control = 'no-cache'
    return response


def _send_inventory(req, resource_provider, inventory, status=200):
    """Send a JSON representation of one single inventory."""
    response = req.response
    response.status = status
    response.body = encodeutils.to_utf8(jsonutils.dumps(_serialize_inventory(
        inventory, generation=resource_provider.generation)))
    response.content_type = 'application/json'
    want_version = req.environ[microversion.MICROVERSION_ENVIRON]
    if want_version.matches((1, 15)):
        modified = util.pick_last_modified(None, inventory)
        response.last_modified = modified
        response.cache_control = 'no-cache'
    return response


def _serialize_inventory(inventory, generation=None):
    """Turn a single inventory into a dictionary."""
    data = {
        field: getattr(inventory, field)
        for field in OUTPUT_INVENTORY_FIELDS
    }
    if generation:
        data['resource_provider_generation'] = generation
    return data


def _serialize_inventories(inventories, generation):
    """Turn a list of inventories in a dict by resource class."""
    inventories_by_class = {inventory.resource_class: inventory
                            for inventory in inventories}
    inventories_dict = {}
    last_modified = None
    for resource_class, inventory in inventories_by_class.items():
        last_modified = util.pick_last_modified(last_modified, inventory)
        inventories_dict[resource_class] = _serialize_inventory(
            inventory, generation=None)
    return ({'resource_provider_generation': generation,
             'inventories': inventories_dict}, last_modified)


def _validate_inventory_capacity(version, inventories):
    """Validate inventory capacity.

    :param version: request microversion.
    :param inventories: One Inventory or a list of Inventory objects to
                        validate capacities of.
    :raises: exception.InvalidInventoryCapacityReservedCanBeTotal if request
        microversion is 1.26 or higher and any inventory has capacity < 0.
    :raises: exception.InvalidInventoryCapacity if request
        microversion is lower than 1.26 and any inventory has capacity <= 0.
    """
    if not version.matches((1, 26)):
        op = operator.le
        exc_class = exception.InvalidInventoryCapacity
    else:
        op = operator.lt
        exc_class = exception.InvalidInventoryCapacityReservedCanBeTotal
    if isinstance(inventories, inv_obj.Inventory):
        inventories = [inventories]
    for inventory in inventories:
        if op(inventory.capacity, 0):
            raise exc_class(
                resource_class=inventory.resource_class,
                resource_provider=inventory.resource_provider.uuid)


@wsgi_wrapper.PlacementWsgify
@util.require_content('application/json')
def create_inventory(req):
    """POST to create one inventory.

    On success return a 201 response, a location header pointing
    to the newly created inventory and an application/json representation
    of the inventory.
    """
    context = req.environ['placement.context']
    context.can(policies.CREATE)
    uuid = util.wsgi_path_item(req.environ, 'uuid')
    resource_provider = rp_obj.ResourceProvider.get_by_uuid(
        context, uuid)
    data = _extract_inventory(req.body, schema.POST_INVENTORY_SCHEMA)
    resource_class = data.pop('resource_class')

    inventory = make_inventory_object(resource_provider,
                                      resource_class,
                                      **data)

    try:
        _validate_inventory_capacity(
            req.environ[microversion.MICROVERSION_ENVIRON], inventory)
        resource_provider.add_inventory(inventory)
    except (exception.ConcurrentUpdateDetected,
            db_exc.DBDuplicateEntry) as exc:
        raise webob.exc.HTTPConflict(
            'Update conflict: %(error)s' % {'error': exc},
            comment=errors.CONCURRENT_UPDATE)
    except (exception.InvalidInventoryCapacity,
            exception.NotFound) as exc:
        raise webob.exc.HTTPBadRequest(
            'Unable to create inventory for resource provider '
            '%(rp_uuid)s: %(error)s' % {'rp_uuid': resource_provider.uuid,
                                        'error': exc})

    response = req.response
    response.location = util.inventory_url(
        req.environ, resource_provider, resource_class)
    return _send_inventory(req, resource_provider, inventory,
                           status=201)


@wsgi_wrapper.PlacementWsgify
def delete_inventory(req):
    """DELETE to destroy a single inventory.

    If the inventory is in use or resource provider generation is out
    of sync return a 409.

    On success return a 204 and an empty body.
    """
    context = req.environ['placement.context']
    context.can(policies.DELETE)
    uuid = util.wsgi_path_item(req.environ, 'uuid')
    resource_class = util.wsgi_path_item(req.environ, 'resource_class')

    resource_provider = rp_obj.ResourceProvider.get_by_uuid(
        context, uuid)
    try:
        resource_provider.delete_inventory(resource_class)
    except (exception.ConcurrentUpdateDetected,
            exception.InventoryInUse) as exc:
        raise webob.exc.HTTPConflict(
            'Unable to delete inventory of class %(class)s: %(error)s' %
            {'class': resource_class, 'error': exc},
            comment=errors.CONCURRENT_UPDATE)
    except exception.NotFound as exc:
        raise webob.exc.HTTPNotFound(
            'No inventory of class %(class)s found for delete: %(error)s' %
            {'class': resource_class, 'error': exc})

    response = req.response
    response.status = 204
    response.content_type = None
    return response


@wsgi_wrapper.PlacementWsgify
@util.check_accept('application/json')
def get_inventories(req):
    """GET a list of inventories.

    On success return a 200 with an application/json body representing
    a collection of inventories.
    """
    context = req.environ['placement.context']
    context.can(policies.LIST)
    uuid = util.wsgi_path_item(req.environ, 'uuid')
    try:
        rp = rp_obj.ResourceProvider.get_by_uuid(context, uuid)
    except exception.NotFound as exc:
        raise webob.exc.HTTPNotFound(
            "No resource provider with uuid %(uuid)s found : %(error)s" %
            {'uuid': uuid, 'error': exc})

    inv_list = inv_obj.get_all_by_resource_provider(context, rp)

    return _send_inventories(req, rp, inv_list)


@wsgi_wrapper.PlacementWsgify
@util.check_accept('application/json')
def get_inventory(req):
    """GET one inventory.

    On success return a 200 an application/json body representing one
    inventory.
    """
    context = req.environ['placement.context']
    context.can(policies.SHOW)
    uuid = util.wsgi_path_item(req.environ, 'uuid')
    resource_class = util.wsgi_path_item(req.environ, 'resource_class')
    try:
        rp = rp_obj.ResourceProvider.get_by_uuid(context, uuid)
    except exception.NotFound as exc:
        raise webob.exc.HTTPNotFound(
            "No resource provider with uuid %(uuid)s found : %(error)s" %
            {'uuid': uuid, 'error': exc})

    inv_list = inv_obj.get_all_by_resource_provider(context, rp)
    inventory = inv_obj.find(inv_list, resource_class)

    if not inventory:
        raise webob.exc.HTTPNotFound(
            'No inventory of class %(class)s for %(rp_uuid)s' %
            {'class': resource_class, 'rp_uuid': uuid})

    return _send_inventory(req, rp, inventory)


@wsgi_wrapper.PlacementWsgify
@util.require_content('application/json')
def set_inventories(req):
    """PUT to set all inventory for a resource provider.

    Create, update and delete inventory as required to reset all
    the inventory.

    If the resource generation is out of sync, return a 409.
    If an inventory to be deleted is in use, return a 409.
    If any inventory to be created or updated has settings which are
    invalid (for example reserved exceeds capacity), return a 400.

    On success return a 200 with an application/json body representing
    the inventories.
    """
    context = req.environ['placement.context']
    context.can(policies.UPDATE)
    uuid = util.wsgi_path_item(req.environ, 'uuid')
    resource_provider = rp_obj.ResourceProvider.get_by_uuid(
        context, uuid)

    data = _extract_inventories(req.body, schema.PUT_INVENTORY_SCHEMA)
    if data['resource_provider_generation'] != resource_provider.generation:
        raise webob.exc.HTTPConflict(
            'resource provider generation conflict',
            comment=errors.CONCURRENT_UPDATE)

    inventories = []
    for res_class, inventory_data in data['inventories'].items():
        inventory = make_inventory_object(
            resource_provider, res_class, **inventory_data)
        inventories.append(inventory)

    try:
        _validate_inventory_capacity(
            req.environ[microversion.MICROVERSION_ENVIRON], inventories)
        resource_provider.set_inventory(inventories)
    except exception.ResourceClassNotFound as exc:
        raise webob.exc.HTTPBadRequest(
            'Unknown resource class in inventory for resource provider '
            '%(rp_uuid)s: %(error)s' % {'rp_uuid': resource_provider.uuid,
                                        'error': exc})
    except exception.InventoryWithResourceClassNotFound as exc:
        raise webob.exc.HTTPConflict(
            'Race condition detected when setting inventory. No inventory '
            'record with resource class for resource provider '
            '%(rp_uuid)s: %(error)s' % {'rp_uuid': resource_provider.uuid,
                                        'error': exc})
    except (exception.ConcurrentUpdateDetected,
            db_exc.DBDuplicateEntry) as exc:
        raise webob.exc.HTTPConflict(
            'update conflict: %(error)s' % {'error': exc},
            comment=errors.CONCURRENT_UPDATE)
    except exception.InventoryInUse as exc:
        raise webob.exc.HTTPConflict(
            'update conflict: %(error)s' % {'error': exc},
            comment=errors.INVENTORY_INUSE)
    except exception.InvalidInventoryCapacity as exc:
        raise webob.exc.HTTPBadRequest(
            'Unable to update inventory for resource provider '
            '%(rp_uuid)s: %(error)s' % {'rp_uuid': resource_provider.uuid,
                                        'error': exc})

    return _send_inventories(req, resource_provider, inventories)


@wsgi_wrapper.PlacementWsgify
@microversion.version_handler('1.5', status_code=405)
def delete_inventories(req):
    """DELETE all inventory for a resource provider.

    Delete inventory as required to reset all the inventory.
    If an inventory to be deleted is in use, return a 409 Conflict.
    On success return a 204 No content.
    Return 405 Method Not Allowed if the wanted microversion does not match.
    """
    context = req.environ['placement.context']
    context.can(policies.DELETE)
    uuid = util.wsgi_path_item(req.environ, 'uuid')
    resource_provider = rp_obj.ResourceProvider.get_by_uuid(
        context, uuid)

    try:
        resource_provider.set_inventory([])
    except exception.ConcurrentUpdateDetected:
        raise webob.exc.HTTPConflict(
            'Unable to delete inventory for resource provider '
            '%(rp_uuid)s because the inventory was updated by '
            'another process. Please retry your request.' %
            {'rp_uuid': resource_provider.uuid},
            comment=errors.CONCURRENT_UPDATE)
    except exception.InventoryInUse as ex:
        raise webob.exc.HTTPConflict(ex.format_message(),
                                     comment=errors.INVENTORY_INUSE)

    response = req.response
    response.status = 204
    response.content_type = None

    return response


@wsgi_wrapper.PlacementWsgify
@util.require_content('application/json')
def update_inventory(req):
    """PUT to update one inventory.

    If the resource generation is out of sync, return a 409.
    If the inventory has settings which are invalid (for example
    reserved exceeds capacity), return a 400.

    On success return a 200 with an application/json body representing
    the inventory.
    """
    context = req.environ['placement.context']
    context.can(policies.UPDATE)
    uuid = util.wsgi_path_item(req.environ, 'uuid')
    resource_class = util.wsgi_path_item(req.environ, 'resource_class')

    resource_provider = rp_obj.ResourceProvider.get_by_uuid(
        context, uuid)

    data = _extract_inventory(req.body, schema.BASE_INVENTORY_SCHEMA)
    if data['resource_provider_generation'] != resource_provider.generation:
        raise webob.exc.HTTPConflict(
            'resource provider generation conflict',
            comment=errors.CONCURRENT_UPDATE)

    inventory = make_inventory_object(resource_provider,
                                      resource_class,
                                      **data)

    try:
        _validate_inventory_capacity(
            req.environ[microversion.MICROVERSION_ENVIRON], inventory)
        resource_provider.update_inventory(inventory)
    except (exception.ConcurrentUpdateDetected,
            db_exc.DBDuplicateEntry) as exc:
        raise webob.exc.HTTPConflict(
            'update conflict: %(error)s' % {'error': exc},
            comment=errors.CONCURRENT_UPDATE)
    except exception.InventoryWithResourceClassNotFound as exc:
        raise webob.exc.HTTPBadRequest(
            'No inventory record with resource class for resource provider '
            '%(rp_uuid)s: %(error)s' % {'rp_uuid': resource_provider.uuid,
                                        'error': exc})
    except exception.InvalidInventoryCapacity as exc:
        raise webob.exc.HTTPBadRequest(
            'Unable to update inventory for resource provider '
            '%(rp_uuid)s: %(error)s' % {'rp_uuid': resource_provider.uuid,
                                        'error': exc})

    return _send_inventory(req, resource_provider, inventory)
