#    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.

import datetime
from http import client as http_client

from ironic_lib import metrics_utils
from oslo_utils import uuidutils
import pecan
from webob import exc as webob_exc
import wsme

from ironic import api
from ironic.api.controllers import base
from ironic.api.controllers import link
from ironic.api.controllers.v1 import collection
from ironic.api.controllers.v1 import notification_utils as notify
from ironic.api.controllers.v1 import types
from ironic.api.controllers.v1 import utils as api_utils
from ironic.api import expose
from ironic.api import types as atypes
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import policy
from ironic.common import states as ir_states
from ironic import objects

METRICS = metrics_utils.get_metrics_logger(__name__)


def hide_fields_in_newer_versions(obj):
    # if requested version is < 1.60, hide owner field
    if not api_utils.allow_allocation_owner():
        obj.owner = atypes.Unset


class Allocation(base.APIBase):
    """API representation of an allocation.

    This class enforces type checking and value constraints, and converts
    between the internal object model and the API representation of a
    allocation.
    """

    uuid = types.uuid
    """Unique UUID for this allocation"""

    extra = {str: types.jsontype}
    """This allocation's meta data"""

    node_uuid = atypes.wsattr(types.uuid, readonly=True)
    """The UUID of the node this allocation belongs to"""

    node = atypes.wsattr(str)
    """The node to backfill the allocation for (POST only)"""

    name = atypes.wsattr(str)
    """The logical name for this allocation"""

    links = atypes.wsattr([link.Link], readonly=True)
    """A list containing a self link and associated allocation links"""

    state = atypes.wsattr(str, readonly=True)
    """The current state of the allocation"""

    last_error = atypes.wsattr(str, readonly=True)
    """Last error that happened to this allocation"""

    resource_class = atypes.wsattr(atypes.StringType(max_length=80))
    """Requested resource class for this allocation"""

    owner = atypes.wsattr(str)
    """Owner of allocation"""

    # NOTE(dtantsur): candidate_nodes is a list of UUIDs on the database level,
    # but the API level also accept names, converting them on fly.
    candidate_nodes = atypes.wsattr([str])
    """Candidate nodes for this allocation"""

    traits = atypes.wsattr([str])
    """Requested traits for the allocation"""

    def __init__(self, **kwargs):
        self.fields = []
        fields = list(objects.Allocation.fields)
        # NOTE: node_uuid is not part of objects.Allocation.fields
        #       because it's an API-only attribute
        fields.append('node_uuid')
        for field in fields:
            # Skip fields we do not expose.
            if not hasattr(self, field):
                continue
            self.fields.append(field)
            setattr(self, field, kwargs.get(field, atypes.Unset))

    @staticmethod
    def _convert_with_links(allocation, url):
        """Add links to the allocation."""
        # This field is only used in POST, never return it.
        allocation.node = atypes.Unset
        allocation.links = [
            link.Link.make_link('self', url, 'allocations', allocation.uuid),
            link.Link.make_link('bookmark', url, 'allocations',
                                allocation.uuid, bookmark=True)
        ]
        return allocation

    @classmethod
    def convert_with_links(cls, rpc_allocation, fields=None, sanitize=True):
        """Add links to the allocation."""
        allocation = Allocation(**rpc_allocation.as_dict())

        if rpc_allocation.node_id:
            try:
                allocation.node_uuid = objects.Node.get_by_id(
                    api.request.context,
                    rpc_allocation.node_id).uuid
            except exception.NodeNotFound:
                allocation.node_uuid = None
        else:
            allocation.node_uuid = None

        if fields is not None:
            api_utils.check_for_invalid_fields(fields, allocation.fields)

        # Make the default values consistent between POST and GET API
        if allocation.candidate_nodes is None:
            allocation.candidate_nodes = []
        if allocation.traits is None:
            allocation.traits = []

        allocation = cls._convert_with_links(allocation,
                                             api.request.host_url)

        if not sanitize:
            return allocation

        allocation.sanitize(fields)

        return allocation

    def sanitize(self, fields=None):
        """Removes sensitive and unrequested data.

        Will only keep the fields specified in the ``fields`` parameter.

        :param fields:
            list of fields to preserve, or ``None`` to preserve them all
        :type fields: list of str
        """

        hide_fields_in_newer_versions(self)

        if fields is not None:
            self.unset_fields_except(fields)

    @classmethod
    def sample(cls):
        """Return a sample of the allocation."""
        sample = cls(uuid='a594544a-2daf-420c-8775-17a8c3e0852f',
                     node_uuid='7ae81bb3-dec3-4289-8d6c-da80bd8001ae',
                     name='node1-allocation-01',
                     state=ir_states.ALLOCATING,
                     last_error=None,
                     resource_class='baremetal',
                     traits=['CUSTOM_GPU'],
                     candidate_nodes=[],
                     extra={'foo': 'bar'},
                     created_at=datetime.datetime(2000, 1, 1, 12, 0, 0),
                     updated_at=datetime.datetime(2000, 1, 1, 12, 0, 0),
                     owner=None)
        return cls._convert_with_links(sample, 'http://localhost:6385')


class AllocationCollection(collection.Collection):
    """API representation of a collection of allocations."""

    allocations = [Allocation]
    """A list containing allocation objects"""

    def __init__(self, **kwargs):
        self._type = 'allocations'

    @staticmethod
    def convert_with_links(rpc_allocations, limit, url=None, fields=None,
                           **kwargs):
        collection = AllocationCollection()
        collection.allocations = [
            Allocation.convert_with_links(p, fields=fields, sanitize=False)
            for p in rpc_allocations
        ]
        collection.next = collection.get_next(limit, url=url, fields=fields,
                                              **kwargs)

        for item in collection.allocations:
            item.sanitize(fields=fields)

        return collection

    @classmethod
    def sample(cls):
        """Return a sample of the allocation."""
        sample = cls()
        sample.allocations = [Allocation.sample()]
        return sample


class AllocationPatchType(types.JsonPatchType):

    _api_base = Allocation


class AllocationsController(pecan.rest.RestController):
    """REST controller for allocations."""

    invalid_sort_key_list = ['extra', 'candidate_nodes', 'traits']

    @pecan.expose()
    def _route(self, args, request=None):
        if not api_utils.allow_allocations():
            msg = _("The API version does not allow allocations")
            if api.request.method == "GET":
                raise webob_exc.HTTPNotFound(msg)
            else:
                raise webob_exc.HTTPMethodNotAllowed(msg)
        return super(AllocationsController, self)._route(args, request)

    def _get_allocations_collection(self, node_ident=None, resource_class=None,
                                    state=None, owner=None, marker=None,
                                    limit=None, sort_key='id', sort_dir='asc',
                                    resource_url=None, fields=None):
        """Return allocations collection.

        :param node_ident: UUID or name of a node.
        :param marker: Pagination marker for large data sets.
        :param limit: Maximum number of resources to return in a single result.
        :param sort_key: Column to sort results by. Default: id.
        :param sort_dir: Direction to sort. "asc" or "desc". Default: asc.
        :param resource_url: Optional, URL to the allocation resource.
        :param fields: Optional, a list with a specified set of fields
                       of the resource to be returned.
        :param owner: project_id of owner to filter by
        """
        limit = api_utils.validate_limit(limit)
        sort_dir = api_utils.validate_sort_dir(sort_dir)

        if sort_key in self.invalid_sort_key_list:
            raise exception.InvalidParameterValue(
                _("The sort_key value %(key)s is an invalid field for "
                  "sorting") % {'key': sort_key})

        marker_obj = None
        if marker:
            marker_obj = objects.Allocation.get_by_uuid(api.request.context,
                                                        marker)

        if node_ident:
            try:
                node_uuid = api_utils.get_rpc_node(node_ident).uuid
            except exception.NodeNotFound as exc:
                exc.code = http_client.BAD_REQUEST
                raise
        else:
            node_uuid = None

        possible_filters = {
            'node_uuid': node_uuid,
            'resource_class': resource_class,
            'state': state,
            'owner': owner
        }

        filters = {}
        for key, value in possible_filters.items():
            if value is not None:
                filters[key] = value

        allocations = objects.Allocation.list(api.request.context,
                                              limit=limit,
                                              marker=marker_obj,
                                              sort_key=sort_key,
                                              sort_dir=sort_dir,
                                              filters=filters)
        return AllocationCollection.convert_with_links(allocations, limit,
                                                       url=resource_url,
                                                       fields=fields,
                                                       sort_key=sort_key,
                                                       sort_dir=sort_dir)

    def _check_allowed_allocation_fields(self, fields):
        """Check if fetching a particular field of an allocation is allowed.

        Check if the required version is being requested for fields
        that are only allowed to be fetched in a particular API version.

        :param fields: list or set of fields to check
        :raises: NotAcceptable if a field is not allowed
        """
        if fields is None:
            return
        if 'owner' in fields and not api_utils.allow_allocation_owner():
            raise exception.NotAcceptable()

    @METRICS.timer('AllocationsController.get_all')
    @expose.expose(AllocationCollection, types.uuid_or_name, str,
                   str, types.uuid, int, str, str,
                   types.listtype, str)
    def get_all(self, node=None, resource_class=None, state=None, marker=None,
                limit=None, sort_key='id', sort_dir='asc', fields=None,
                owner=None):
        """Retrieve a list of allocations.

        :param node: UUID or name of a node, to get only allocations for that
                     node.
        :param resource_class: Filter by requested resource class.
        :param state: Filter by allocation state.
        :param marker: pagination marker for large data sets.
        :param limit: maximum number of resources to return in a single result.
                      This value cannot be larger than the value of max_limit
                      in the [api] section of the ironic configuration, or only
                      max_limit resources will be returned.
        :param sort_key: column to sort results by. Default: id.
        :param sort_dir: direction to sort. "asc" or "desc". Default: asc.
        :param fields: Optional, a list with a specified set of fields
                       of the resource to be returned.
        :param owner: Filter by owner.
        """
        owner = api_utils.check_list_policy('allocation', owner)

        self._check_allowed_allocation_fields(fields)
        if owner is not None and not api_utils.allow_allocation_owner():
            raise exception.NotAcceptable()

        return self._get_allocations_collection(node, resource_class, state,
                                                owner, marker, limit,
                                                sort_key, sort_dir,
                                                fields=fields)

    @METRICS.timer('AllocationsController.get_one')
    @expose.expose(Allocation, types.uuid_or_name, types.listtype)
    def get_one(self, allocation_ident, fields=None):
        """Retrieve information about the given allocation.

        :param allocation_ident: UUID or logical name of an allocation.
        :param fields: Optional, a list with a specified set of fields
                       of the resource to be returned.
        """
        rpc_allocation = api_utils.check_allocation_policy_and_retrieve(
            'baremetal:allocation:get', allocation_ident)
        self._check_allowed_allocation_fields(fields)

        return Allocation.convert_with_links(rpc_allocation, fields=fields)

    def _authorize_create_allocation(self, allocation):
        cdict = api.request.context.to_policy_values()

        try:
            policy.authorize('baremetal:allocation:create', cdict, cdict)
            self._check_allowed_allocation_fields(allocation.as_dict())
        except exception.HTTPForbidden:
            owner = cdict.get('project_id')
            if not owner or (allocation.owner and owner != allocation.owner):
                raise
            policy.authorize('baremetal:allocation:create_restricted',
                             cdict, cdict)
            self._check_allowed_allocation_fields(allocation.as_dict())
            allocation.owner = owner

        return allocation

    @METRICS.timer('AllocationsController.post')
    @expose.expose(Allocation, body=Allocation,
                   status_code=http_client.CREATED)
    def post(self, allocation):
        """Create a new allocation.

        :param allocation: an allocation within the request body.
        """
        context = api.request.context
        allocation = self._authorize_create_allocation(allocation)

        if (allocation.name
                and not api_utils.is_valid_logical_name(allocation.name)):
            msg = _("Cannot create allocation with invalid name "
                    "'%(name)s'") % {'name': allocation.name}
            raise exception.Invalid(msg)

        if allocation.traits:
            for trait in allocation.traits:
                api_utils.validate_trait(trait)

        node = None
        if allocation.node is not atypes.Unset:
            if api_utils.allow_allocation_backfill():
                try:
                    node = api_utils.get_rpc_node(allocation.node)
                except exception.NodeNotFound as exc:
                    exc.code = http_client.BAD_REQUEST
                    raise
            else:
                msg = _("Cannot set node when creating an allocation "
                        "in this API version")
                raise exception.Invalid(msg)

        if not allocation.resource_class:
            if node:
                allocation.resource_class = node.resource_class
            else:
                msg = _("The resource_class field is mandatory when not "
                        "backfilling")
                raise exception.Invalid(msg)

        if allocation.candidate_nodes:
            # Convert nodes from names to UUIDs and check their validity
            try:
                converted = api.request.dbapi.check_node_list(
                    allocation.candidate_nodes)
            except exception.NodeNotFound as exc:
                exc.code = http_client.BAD_REQUEST
                raise
            else:
                # Make sure we keep the ordering of candidate nodes.
                allocation.candidate_nodes = [
                    converted[ident] for ident in allocation.candidate_nodes]

        all_dict = allocation.as_dict()

        # NOTE(yuriyz): UUID is mandatory for notifications payload
        if not all_dict.get('uuid'):
            if node and node.instance_uuid:
                # When backfilling without UUID requested, assume that the
                # target instance_uuid is the desired UUID
                all_dict['uuid'] = node.instance_uuid
            else:
                all_dict['uuid'] = uuidutils.generate_uuid()

        new_allocation = objects.Allocation(context, **all_dict)
        if node:
            new_allocation.node_id = node.id
            topic = api.request.rpcapi.get_topic_for(node)
        else:
            topic = api.request.rpcapi.get_random_topic()

        notify.emit_start_notification(context, new_allocation, 'create')
        with notify.handle_error_notification(context, new_allocation,
                                              'create'):
            new_allocation = api.request.rpcapi.create_allocation(
                context, new_allocation, topic)
        notify.emit_end_notification(context, new_allocation, 'create')

        # Set the HTTP Location Header
        api.response.location = link.build_url('allocations',
                                               new_allocation.uuid)
        return Allocation.convert_with_links(new_allocation)

    def _validate_patch(self, patch):
        allowed_fields = ['name', 'extra']
        fields = set()
        for p in patch:
            path = p['path'].split('/')[1]
            if path not in allowed_fields:
                msg = _("Cannot update %s in an allocation. Only 'name' and "
                        "'extra' are allowed to be updated.")
                raise exception.Invalid(msg % p['path'])
            fields.add(path)
        self._check_allowed_allocation_fields(fields)

    @METRICS.timer('AllocationsController.patch')
    @wsme.validate(types.uuid, [AllocationPatchType])
    @expose.expose(Allocation, types.uuid_or_name, body=[AllocationPatchType])
    def patch(self, allocation_ident, patch):
        """Update an existing allocation.

        :param allocation_ident: UUID or logical name of an allocation.
        :param patch: a json PATCH document to apply to this allocation.
        """
        if not api_utils.allow_allocation_update():
            raise webob_exc.HTTPMethodNotAllowed(_(
                "The API version does not allow updating allocations"))

        context = api.request.context
        rpc_allocation = api_utils.check_allocation_policy_and_retrieve(
            'baremetal:allocation:update', allocation_ident)
        self._validate_patch(patch)
        names = api_utils.get_patch_values(patch, '/name')
        for name in names:
            if name and not api_utils.is_valid_logical_name(name):
                msg = _("Cannot update allocation with invalid name "
                        "'%(name)s'") % {'name': name}
                raise exception.Invalid(msg)
        allocation_dict = rpc_allocation.as_dict()
        allocation = Allocation(**api_utils.apply_jsonpatch(allocation_dict,
                                                            patch))
        # Update only the fields that have changed
        for field in objects.Allocation.fields:
            try:
                patch_val = getattr(allocation, field)
            except AttributeError:
                # Ignore fields that aren't exposed in the API
                continue
            if patch_val == atypes.Unset:
                patch_val = None
            if rpc_allocation[field] != patch_val:
                rpc_allocation[field] = patch_val

        notify.emit_start_notification(context, rpc_allocation, 'update')
        with notify.handle_error_notification(context,
                                              rpc_allocation, 'update'):
            rpc_allocation.save()
        notify.emit_end_notification(context, rpc_allocation, 'update')
        return Allocation.convert_with_links(rpc_allocation)

    @METRICS.timer('AllocationsController.delete')
    @expose.expose(None, types.uuid_or_name,
                   status_code=http_client.NO_CONTENT)
    def delete(self, allocation_ident):
        """Delete an allocation.

        :param allocation_ident: UUID or logical name of an allocation.
        """
        context = api.request.context
        rpc_allocation = api_utils.check_allocation_policy_and_retrieve(
            'baremetal:allocation:delete', allocation_ident)
        if rpc_allocation.node_id:
            node_uuid = objects.Node.get_by_id(api.request.context,
                                               rpc_allocation.node_id).uuid
        else:
            node_uuid = None

        notify.emit_start_notification(context, rpc_allocation, 'delete',
                                       node_uuid=node_uuid)
        with notify.handle_error_notification(context, rpc_allocation,
                                              'delete', node_uuid=node_uuid):
            topic = api.request.rpcapi.get_random_topic()
            api.request.rpcapi.destroy_allocation(context, rpc_allocation,
                                                  topic)
        notify.emit_end_notification(context, rpc_allocation, 'delete',
                                     node_uuid=node_uuid)


class NodeAllocationController(pecan.rest.RestController):
    """REST controller for allocations."""

    invalid_sort_key_list = ['extra', 'candidate_nodes', 'traits']

    @pecan.expose()
    def _route(self, args, request=None):
        if not api_utils.allow_allocations():
            raise webob_exc.HTTPNotFound(_(
                "The API version does not allow allocations"))
        return super(NodeAllocationController, self)._route(args, request)

    def __init__(self, node_ident):
        super(NodeAllocationController, self).__init__()
        self.parent_node_ident = node_ident
        self.inner = AllocationsController()

    @METRICS.timer('NodeAllocationController.get_all')
    @expose.expose(Allocation, types.listtype)
    def get_all(self, fields=None):
        cdict = api.request.context.to_policy_values()
        policy.authorize('baremetal:allocation:get', cdict, cdict)

        result = self.inner._get_allocations_collection(self.parent_node_ident,
                                                        fields=fields)
        try:
            return result.allocations[0]
        except IndexError:
            raise exception.AllocationNotFound(
                _("Allocation for node %s was not found") %
                self.parent_node_ident)

    @METRICS.timer('NodeAllocationController.delete')
    @expose.expose(None, status_code=http_client.NO_CONTENT)
    def delete(self):
        context = api.request.context
        cdict = context.to_policy_values()
        policy.authorize('baremetal:allocation:delete', cdict, cdict)

        rpc_node = api_utils.get_rpc_node_with_suffix(self.parent_node_ident)
        allocations = objects.Allocation.list(
            api.request.context,
            filters={'node_uuid': rpc_node.uuid})

        try:
            rpc_allocation = allocations[0]
        except IndexError:
            raise exception.AllocationNotFound(
                _("Allocation for node %s was not found") %
                self.parent_node_ident)

        notify.emit_start_notification(context, rpc_allocation, 'delete',
                                       node_uuid=rpc_node.uuid)
        with notify.handle_error_notification(context, rpc_allocation,
                                              'delete',
                                              node_uuid=rpc_node.uuid):
            topic = api.request.rpcapi.get_random_topic()
            api.request.rpcapi.destroy_allocation(context, rpc_allocation,
                                                  topic)
        notify.emit_end_notification(context, rpc_allocation, 'delete',
                                     node_uuid=rpc_node.uuid)
