# Copyright 2017 Cisco Systems, Inc.
# 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.

import os
import socket
import sys
from urllib import parse as urlparse

from neutron.agent import rpc as agent_rpc
from neutron.common import config as common_config
from neutron.conf.agent import common as agent_config
from neutron_lib.agent import topics
from neutron_lib import constants as n_const
from neutron_lib import context
from openstack import exceptions as sdk_exc
from oslo_config import cfg
from oslo_log import log as logging
import oslo_messaging
from oslo_service import loopingcall
from oslo_service import service
from oslo_utils import timeutils
from oslo_utils import uuidutils
from tooz import hashring

from networking_baremetal import constants
from networking_baremetal import ironic_client

CONF = cfg.CONF
LOG = logging.getLogger(__name__)
CONF.import_group('AGENT', 'neutron.plugins.ml2.drivers.agent.config')


def list_opts():
    return [('agent', agent_config.AGENT_STATE_OPTS)]


def _get_notification_transport_url():
    url = urlparse.urlparse(CONF.transport_url)
    if (CONF.oslo_messaging_rabbit.amqp_auto_delete is False
            and not getattr(CONF.oslo_messaging_rabbit, 'rabbit_quorum_queue',
                            None)):
        q = urlparse.parse_qs(url.query)
        q.update({'amqp_auto_delete': ['true']})
        query = urlparse.urlencode({k: v[0] for k, v in q.items()})
        url = url._replace(query=query)
    return urlparse.urlunparse(url)


def _set_up_notifier(transport, uuid):
    return oslo_messaging.Notifier(
        transport,
        publisher_id='ironic-neutron-agent-' + uuid,
        driver='messagingv2',
        topics=['ironic-neutron-agent-member-manager'])


def _set_up_listener(transport, agent_id):
    targets = [
        oslo_messaging.Target(topic='ironic-neutron-agent-member-manager')]
    endpoints = [HashRingMemberManagerNotificationEndpoint()]
    return oslo_messaging.get_notification_listener(
        transport, targets, endpoints, pool=agent_id)


class HashRingMemberManagerNotificationEndpoint(object):
    """Class variables members and hashring is shared by all instances"""

    filter_rule = oslo_messaging.NotificationFilter(
        publisher_id='^ironic-neutron-agent.*')

    members = []
    hashring = hashring.HashRing([])

    def info(self, ctxt, publisher_id, event_type, payload, metadata):

        timestamp = timeutils.utcnow_ts()
        # Add members or update timestamp for existing members
        if payload['id'] not in [x['id'] for x in self.members]:
            try:
                LOG.info('Adding member id %s on host %s to hashring.',
                         payload['id'], payload['host'])
                self.hashring.add_node(payload['id'])
                self.members.append(payload)
            except Exception:
                LOG.exception('Failed to add member %s to hash ring!',
                              payload['id'])
        else:
            for member in self.members:
                if payload['id'] == member['id']:
                    member['timestamp'] = payload['timestamp']

        # Remove members that have not checked in for a while
        for member in self.members:
            if (timestamp - member['timestamp']) > (
                    CONF.AGENT.report_interval * 3):
                try:
                    LOG.info('Removing member %s on host %s from hashring.',
                             member['id'], member['host'])
                    self.hashring.remove_node(member['id'])
                    self.members.remove(member)
                except Exception:
                    LOG.exception('Failed to remove member %s from hash ring!',
                                  member['id'])

        return oslo_messaging.NotificationResult.HANDLED


class BaremetalNeutronAgent(service.ServiceBase):

    def __init__(self):
        self.context = context.get_admin_context_without_session()
        self.agent_id = uuidutils.generate_uuid(dashed=True)
        self.agent_host = socket.gethostname()
        self.heartbeat = None
        self.notify_agents = None

        # Set up oslo_messaging notifier and listener to keep track of other
        # members
        # NOTE(hjensas): Override the control_exchange for the notification
        # transport to allow setting amqp_auto_delete = true.
        # TODO(hjensas): Remove this and override the exchange when setting up
        # the notifier once the fix for bug is available.
        #   https://bugs.launchpad.net/oslo.messaging/+bug/1814797
        CONF.set_override('control_exchange', 'ironic-neutron-agent')
        self.transport = oslo_messaging.get_notification_transport(
            CONF, url=_get_notification_transport_url())
        self.notifier = _set_up_notifier(self.transport, self.agent_id)
        # Note(hjensas): We need to have listener consuming the non-pool queue.
        # See bug: https://bugs.launchpad.net/oslo.messaging/+bug/1814544
        self.listener = _set_up_listener(self.transport, None)
        self.pool_listener = _set_up_listener(self.transport, '-'.join(
            ['ironic-neutron-agent-member-manager-pool', self.agent_id]))

        self.member_manager = HashRingMemberManagerNotificationEndpoint()

        self.state_rpc = agent_rpc.PluginReportStateAPI(topics.REPORTS)
        self.ironic_client = ironic_client.get_client()
        self.reported_nodes = {}
        LOG.info('Agent networking-baremetal initialized.')

    def start(self):
        LOG.info('Starting agent networking-baremetal.')
        cfg.CONF.log_opt_values(LOG, logging.INFO)
        self.pool_listener.start()
        self.listener.start()
        self.notify_agents = loopingcall.FixedIntervalLoopingCall(
            self._notify_peer_agents)
        self.notify_agents.start(interval=(CONF.AGENT.report_interval / 3))
        self.heartbeat = loopingcall.FixedIntervalLoopingCall(
            self._report_state)
        self.heartbeat.start(interval=CONF.AGENT.report_interval,
                             initial_delay=CONF.AGENT.report_interval)
        self.cleanup_stale_agents()

    def stop(self, failure=False):
        LOG.info('Stopping agent networking-baremetal.')
        if self.heartbeat:
            self.heartbeat.stop()
        if self.notify_agents:
            self.notify_agents.stop()
        self.listener.stop()
        self.pool_listener.stop()
        self.listener.wait()
        self.pool_listener.wait()
        if failure:
            # This will generate a SIGABORT for the process which forces it
            # to exit, which seems cleaner to force the process to exit
            # than os.exit and avoids threading constraints.
            os.abort()

    def reset(self):
        LOG.info('Resetting agent networking-baremetal.')
        if self.heartbeat:
            self.heartbeat.stop()
        if self.notify_agents:
            self.notify_agents.stop()
        self.listener.stop()
        self.pool_listener.stop()
        self.listener.wait()
        self.pool_listener.wait()

    def wait(self):
        pass

    def _notify_peer_agents(self):
        try:
            self.notifier.info({
                'ironic-neutron-agent': 'heartbeat'},
                'ironic-neutron-agent-member-manager',
                {'id': self.agent_id,
                 'host': self.agent_host,
                 'timestamp': timeutils.utcnow_ts()})
        except Exception:
            LOG.exception('Failed to send hash ring membership heartbeat!')

    def get_template_node_state(self, node_uuid):
        return {
            'binary': constants.BAREMETAL_BINARY,
            'host': node_uuid,
            'topic': n_const.L2_AGENT_TOPIC,
            'configurations': {
                'bridge_mappings': {},
                'log_agent_heartbeats': CONF.AGENT.log_agent_heartbeats,
            },
            'start_flag': False,
            'agent_type': constants.BAREMETAL_AGENT_TYPE,
            'action': 'update'}

    def _report_state(self):
        node_states = {}
        ironic_ports = self.ironic_client.ports(details=True)

        # NOTE: the above calls returns a generator, so we need to handle
        # exceptions that happen just before the first loop iteration, when
        # the actual request to ironic happens
        try:
            for port in ironic_ports:
                node = port.node_id
                if (self.agent_id not in
                        self.member_manager.hashring[node.encode('utf-8')]):
                    continue
                template_node_state = self.get_template_node_state(node)
                node_states.setdefault(node, template_node_state)
                mapping = node_states[
                    node]["configurations"]["bridge_mappings"]
                if port.physical_network is not None:
                    mapping[port.physical_network] = "yes"
        except sdk_exc.OpenStackCloudException:
            LOG.exception("Failed to get ironic ports data! "
                          "Not reporting state.")
            try:
                # Replace the client, just to be on the safe side in
                # the event there was some sort of hard/breaking failure.
                self.ironic_client = ironic_client.get_client()
            except Exception:
                # Failed to re-launch a new client, aborting.
                self.stop(failure=True)
            return
        abort_operation = False
        for state in node_states.values():
            # If the node was not previously reported with current
            # configuration set the start_flag True.
            # NOTE(TheJulia) reported_nodes is an internal list of nodes
            # we *have* updated.
            if not state['configurations'] == self.reported_nodes.get(
                    state['host']):
                state.update({'start_flag': True})
                LOG.info('Reporting state for host agent %s with new '
                         'configuration: %s',
                         state['host'], state['configurations'])
            try:
                LOG.debug('Reporting state for host: %s with configuration: '
                          '%s', state['host'], state['configurations'])
                self.state_rpc.report_state(self.context, state)
            except AttributeError:
                # This means the server does not support report_state
                LOG.exception("Neutron server does not support state report. "
                              "State report for this agent will be disabled.")
                # Don't continue reporting the remaining agents in this case.
                abort_operation = True
                break
            except Exception:
                LOG.exception("Failed reporting state!")
                # Don't continue reporting the remaining nodes if one failed.
                return
            self.reported_nodes.update(
                {state['host']: state['configurations']})

        # Identify nodes that are no longer present in Ironic by subtracting
        # the keys of `node_states` from the keys of `reported_nodes`. Then
        # delete agents for nodes that are no longer present.
        deleted_nodes = self.reported_nodes.keys() - node_states.keys()
        deleted_agents = self._delete_agents(deleted_nodes)
        for node in deleted_agents:
            self.reported_nodes.pop(node)

        if abort_operation:
            # We don't expect the agent to work, and as such we should call
            # stop so the program unwinds and begins to exit.
            self.stop(failure=True)

    def _get_down_agents(self):
        """Retrieves a list of inactive Baremetal agents.

        Fetch a list of inactive Baremetal agents. It interacts with
        the state_rpc object to call the 'get_agents' method, which
        retrieves agents based on the provided parameters.

        :returns: (list) Inactive Baremetal agents.
        """
        down_bm_agents = []
        try:
            down_bm_agents = self.state_rpc.get_agents(
                self.context,
                agent_type=constants.BAREMETAL_AGENT_TYPE,
                is_active=False)
        except oslo_messaging.NoSuchMethod:
            LOG.warning("Neutron server doesn't support "
                        "`get_agents` endpoint.")

        return down_bm_agents

    def _get_nodes_not_found(self, down_bm_agents):
        """Identifies nodes that are not found in the Ironic

        The method iterates over each agent in the 'down_bm_agents' list.
        For each agent, it attempts to retrieve the corresponding node using
        the Ironic client's 'get_node' method. If the node is not found the
        node is appended to the 'nodes_not_found' list.

        :param down_bm_agents: (list) Agents that are down in Neutron.
        :return: (list) Nodes that are not found in Ironic.
        """
        nodes_not_found = []
        for agent in down_bm_agents:
            node = agent['host']
            try:
                self.ironic_client.get_node(node)
            except sdk_exc.NotFoundException:
                nodes_not_found.append(node)

        return nodes_not_found

    def _delete_agents(self, nodes, log=True):
        """Delete agents for nodes that are not found in ironic

        Clean up agent records in neutron for ironic nodes that have been
        removed from the system.

        :param nodes_not_found: (list) Nodes that are not found in Ironic.
        :log: (bool) Log the actions taken.
        :return: (list) Agents that have been deleted in Neutron.
        """
        deleted_agents = []
        for node in nodes:
            if log:
                LOG.info('Removing agent for host: %s', node)
            try:
                kwargs = {'host': node,
                          'agent_type': constants.BAREMETAL_AGENT_TYPE}
                self.state_rpc.delete_agent(self.context, **kwargs)
                deleted_agents.append(node)
            except oslo_messaging.NoSuchMethod:
                LOG.warning("Neutron server doesn't support "
                            "`delete_agent` endpoint.")
                break

        return deleted_agents

    def cleanup_stale_agents(self):
        """Cleans up stale baremetal agents

        This method identifies baremetal agents that are marked as
        inactive in the Neutron server and are not associated with
        any nodes in Ironic. It then deletes these stale agents.
        """
        down_bm_agents = self._get_down_agents()
        nodes_not_found = self._get_nodes_not_found(down_bm_agents)
        deleted_agents = self._delete_agents(nodes_not_found, log=False)

        if deleted_agents:
            LOG.info("Stale baremetal agent for hosts was removed: %s",
                     ", ".join(deleted_agents))


def _unregiser_deprecated_opts():
    CONF.reset()
    CONF.unregister_opts(
        [CONF._groups[ironic_client.IRONIC_GROUP]._opts[opt]['opt']
         for opt in ironic_client._deprecated_opts],
        group=ironic_client.IRONIC_GROUP)


def main():
    common_config.register_common_config_options()
    # TODO(hjensas): Imports from neutron in ironic_neutron_agent registers the
    # client options. We need to unregister the options we are deprecating
    # first to avoid DuplicateOptError. Remove this when dropping deprecations.
    _unregiser_deprecated_opts()
    common_config.init(sys.argv[1:])
    common_config.setup_logging()
    agent = BaremetalNeutronAgent()
    # Use service.Launcher for single-process execution to ensure hash ring
    # class variables are shared across all threads. service.launch() spawns
    # worker processes via fork, which isolates class variables and breaks
    # hash ring synchronization. See bug LP#2144384
    launcher = service.Launcher(cfg.CONF, restart_method='mutate')
    launcher.launch_service(agent)
    launcher.wait()
