# Copyright 2016 Red Hat, Inc.
#
#    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 http import client as http_client

from oslo_config import cfg
from oslo_log import log
from pecan import rest

from ironic import api
from ironic.api.controllers import base
from ironic.api.controllers.v1 import node as node_ctl
from ironic.api.controllers.v1 import types
from ironic.api.controllers.v1 import utils as api_utils
from ironic.api import expose
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import policy
from ironic.common import states
from ironic.common import utils
from ironic import objects


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

_LOOKUP_RETURN_FIELDS = ('uuid', 'properties', 'instance_info',
                         'driver_internal_info')


def config(token):
    return {
        'metrics': {
            'backend': CONF.metrics.agent_backend,
            'prepend_host': CONF.metrics.agent_prepend_host,
            'prepend_uuid': CONF.metrics.agent_prepend_uuid,
            'prepend_host_reverse': CONF.metrics.agent_prepend_host_reverse,
            'global_prefix': CONF.metrics.agent_global_prefix
        },
        'metrics_statsd': {
            'statsd_host': CONF.metrics_statsd.agent_statsd_host,
            'statsd_port': CONF.metrics_statsd.agent_statsd_port
        },
        'heartbeat_timeout': CONF.api.ramdisk_heartbeat_timeout,
        'agent_token': token,
        # Not an API version based indicator, passing as configuration
        # as the signifigants indicates support should also be present.
        'agent_token_required': CONF.require_agent_token,
    }


class LookupResult(base.APIBase):
    """API representation of the node lookup result."""

    node = node_ctl.Node
    """The short node representation."""

    config = {str: types.jsontype}
    """The configuration to pass to the ramdisk."""

    @classmethod
    def sample(cls):
        return cls(node=node_ctl.Node.sample(),
                   config={'heartbeat_timeout': 600})

    @classmethod
    def convert_with_links(cls, node):
        token = node.driver_internal_info.get('agent_secret_token')
        node = node_ctl.Node.convert_with_links(node, _LOOKUP_RETURN_FIELDS)
        return cls(node=node, config=config(token))


class LookupController(rest.RestController):
    """Controller handling node lookup for a deploy ramdisk."""

    @property
    def lookup_allowed_states(self):
        if CONF.deploy.fast_track:
            return states.FASTTRACK_LOOKUP_ALLOWED_STATES
        return states.LOOKUP_ALLOWED_STATES

    @expose.expose(LookupResult, types.listtype, types.uuid)
    def get_all(self, addresses=None, node_uuid=None):
        """Look up a node by its MAC addresses and optionally UUID.

        If the "restrict_lookup" option is set to True (the default), limit
        the search to nodes in certain transient states (e.g. deploy wait).

        :param addresses: list of MAC addresses for a node.
        :param node_uuid: UUID of a node.
        :raises: NotFound if requested API version does not allow this
            endpoint.
        :raises: NotFound if suitable node was not found or node's provision
            state is not allowed for the lookup.
        :raises: IncompleteLookup if neither node UUID nor any valid MAC
            address was provided.
        """
        if not api_utils.allow_ramdisk_endpoints():
            raise exception.NotFound()

        cdict = api.request.context.to_policy_values()
        policy.authorize('baremetal:driver:ipa_lookup', cdict, cdict)

        # Validate the list of MAC addresses
        if addresses is None:
            addresses = []

        valid_addresses = []
        invalid_addresses = []
        for addr in addresses:
            try:
                mac = utils.validate_and_normalize_mac(addr)
                valid_addresses.append(mac)
            except exception.InvalidMAC:
                invalid_addresses.append(addr)

        if invalid_addresses:
            node_log = ('' if not node_uuid
                        else '(Node UUID: %s)' % node_uuid)
            LOG.warning('The following MAC addresses "%(addrs)s" are '
                        'invalid and will be ignored by the lookup '
                        'request %(node)s',
                        {'addrs': ', '.join(invalid_addresses),
                         'node': node_log})

        if not valid_addresses and not node_uuid:
            raise exception.IncompleteLookup()

        try:
            if node_uuid:
                node = objects.Node.get_by_uuid(
                    api.request.context, node_uuid)
            else:
                node = objects.Node.get_by_port_addresses(
                    api.request.context, valid_addresses)
        except exception.NotFound:
            # NOTE(dtantsur): we are reraising the same exception to make sure
            # we don't disclose the difference between nodes that are not found
            # at all and nodes in a wrong state by different error messages.
            raise exception.NotFound()

        if (CONF.api.restrict_lookup
                and node.provision_state not in self.lookup_allowed_states):
            raise exception.NotFound()

        if api_utils.allow_agent_token() or CONF.require_agent_token:
            try:
                topic = api.request.rpcapi.get_topic_for(node)
            except exception.NoValidHost as e:
                e.code = http_client.BAD_REQUEST
                raise

            found_node = api.request.rpcapi.get_node_with_token(
                api.request.context, node.uuid, topic=topic)
        else:
            found_node = node
        return LookupResult.convert_with_links(found_node)


class HeartbeatController(rest.RestController):
    """Controller handling heartbeats from deploy ramdisk."""

    @expose.expose(None, types.uuid_or_name, str,
                   str, str, status_code=http_client.ACCEPTED)
    def post(self, node_ident, callback_url, agent_version=None,
             agent_token=None):
        """Process a heartbeat from the deploy ramdisk.

        :param node_ident: the UUID or logical name of a node.
        :param callback_url: the URL to reach back to the ramdisk.
        :param agent_version: The version of the agent that is heartbeating.
            ``None`` indicates that the agent that is heartbeating is a version
            before sending agent_version was introduced so agent v3.0.0 (the
            last release before sending agent_version was introduced) will be
            assumed.
        :raises: NodeNotFound if node with provided UUID or name was not found.
        :raises: InvalidUuidOrName if node_ident is not valid name or UUID.
        :raises: NoValidHost if RPC topic for node could not be retrieved.
        :raises: NotFound if requested API version does not allow this
            endpoint.
        """
        if not api_utils.allow_ramdisk_endpoints():
            raise exception.NotFound()

        if agent_version and not api_utils.allow_agent_version_in_heartbeat():
            raise exception.InvalidParameterValue(
                _('Field "agent_version" not recognised'))

        cdict = api.request.context.to_policy_values()
        policy.authorize('baremetal:node:ipa_heartbeat', cdict, cdict)

        rpc_node = api_utils.get_rpc_node_with_suffix(node_ident)
        dii = rpc_node['driver_internal_info']
        agent_url = dii.get('agent_url')
        # If we have an agent_url on file, and we get something different
        # we should fail because this is unexpected behavior of the agent.
        if agent_url is not None and agent_url != callback_url:
            LOG.error('Received heartbeat for node %(node)s with '
                      'callback URL %(url)s. This is not expected, '
                      'and the heartbeat will not be processed.',
                      {'node': rpc_node.uuid, 'url': callback_url})
            raise exception.Invalid(
                _('Detected change in ramdisk provided '
                  '"callback_url"'))
        # NOTE(TheJulia): If tokens are required, lets go ahead and fail the
        # heartbeat very early on.
        token_required = CONF.require_agent_token
        if token_required and agent_token is None:
            LOG.error('Agent heartbeat received for node %(node)s '
                      'without an agent token.', {'node': node_ident})
            raise exception.InvalidParameterValue(
                _('Agent token is required for heartbeat processing.'))

        try:
            topic = api.request.rpcapi.get_topic_for(rpc_node)
        except exception.NoValidHost as e:
            e.code = http_client.BAD_REQUEST
            raise

        api.request.rpcapi.heartbeat(
            api.request.context, rpc_node.uuid, callback_url,
            agent_version, agent_token, topic=topic)
