# Copyright 2017-2018 FUJITSU LIMITED
#
# 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 oslo_concurrency import lockutils
from oslo_config import cfg
from oslo_log import log as logging

from neutron.agent import securitygroups_rpc
from neutron import manager
from neutron.plugins.ml2.drivers.openvswitch.agent import vlanmanager
from neutron_lib.agent import l2_extension
from neutron_lib import constants as nl_const
from neutron_lib.exceptions import firewall_v2 as f_exc
from neutron_lib import rpc as n_rpc
from neutron_lib.utils import net as nl_net

from neutron_fwaas._i18n import _
from neutron_fwaas.common import fwaas_constants as consts
from neutron_fwaas.services.firewall.service_drivers.agents import\
    firewall_agent_api as api

LOG = logging.getLogger(__name__)

FWAAS_L2_DRIVER = 'neutron.agent.l2.firewall_drivers'
SG_OVS_DRIVER = 'openvswitch'


class FWaaSL2PluginApi(api.FWaaSPluginApiMixin):
    """L2 agent side of FWaaS agent-to-plugin RPC API"""

    def get_firewall_group_for_port(self, context, port_id):
        """Get firewall group is associated with a port"""

        LOG.debug("Get firewall group is associated with port %s", port_id)
        cctxt = self.client.prepare()
        return cctxt.call(context, 'get_firewall_group_for_port',
                          port_id=port_id)

    def set_firewall_group_status(self, context, fwg_id, status, host):
        """Set the status of a group operation."""

        LOG.debug("Fetch firewall group changing status")
        cctxt = self.client.prepare()
        return cctxt.call(context, 'set_firewall_group_status',
                          fwg_id=fwg_id, status=status, host=host)

    def firewall_group_deleted(self, context, fwg_id, host):
        """Notifies the plugin that a firewall group has been deleted."""

        LOG.debug("Notify to the plugin that firewall group has been deleted")
        cctxt = self.client.prepare()
        return cctxt.call(context, 'firewall_group_deleted',
                          fwg_id=fwg_id, host=host)


class FWaaSV2AgentExtension(l2_extension.L2AgentExtension):

    def initialize(self, connection, driver_type):
        """Perform Agent Extension initialization"""

        self.conf = cfg.CONF
        self.vlan_manager = vlanmanager.LocalVlanManager()
        fw_l2_driver_cls = self._load_l2_driver_class(driver_type)
        sg_enabled = securitygroups_rpc.is_firewall_enabled()
        sg_firewall_driver = self.conf.SECURITYGROUP.firewall_driver
        sg_with_ovs = sg_enabled and (sg_firewall_driver == SG_OVS_DRIVER)
        self.driver = manager.NeutronManager.load_class_for_provider(
            FWAAS_L2_DRIVER, fw_l2_driver_cls)(self.agent_api, sg_with_ovs)
        self.plugin_rpc = FWaaSL2PluginApi(
            consts.FIREWALL_PLUGIN, self.conf.host)
        self.start_rpc_listeners()
        self.fwg_map = PortFirewallGroupMap()

    def consume_api(self, agent_api):
        self.agent_api = agent_api

    def start_rpc_listeners(self):
        self.conn = n_rpc.Connection()
        endpoints = [self]
        self.conn.create_consumer(consts.FW_AGENT, endpoints, fanout=False)
        return self.conn.consume_in_threads()

    def _load_l2_driver_class(self, driver_type):
        driver = self.conf.fwaas.firewall_l2_driver or 'noop'
        if driver == api.FW_L2_NOOP_DRIVER:
            return driver

        if driver != driver_type:
            raise Exception(
                _("Firewall l2 driver: %s is not compatible"), driver_type)
        return driver

    def _is_port_layer2(self, port):
        """This function checks if a port belongs to a L2 case.

        Currently both DHCP and router ports are eliminated.
        """

        return port and port.get('device_owner', '').startswith(
            nl_const.DEVICE_OWNER_COMPUTE_PREFIX)

    def _get_firewall_group_ports(self, fwg, host, to_delete=False):
        port_list = []
        port_ids = fwg['del-port-ids'] if to_delete else fwg['add-port-ids']

        LOG.debug("_get_fwg fwg=%(fwg)s ports=%(port)s to_delete=%(delete)s",
                  {'fwg': fwg, 'port': port_ids, 'delete': to_delete})
        for fw_port in port_ids:
            port_detail = fwg['port_details'].get(fw_port)
            if (self._is_port_layer2(port_detail) and
                    port_detail.get('host') == host):
                port_list.append(port_detail)
        return port_list

    @staticmethod
    def _has_ports(fwg, event):
        """Verifying fwg has ports or not

         This function verify applying firewall group on ports
         :param fwg: a fwg object
         :param event: create/update firewall group or
                       create/update/delete port
         :return: True if applying firewall group is fine. Otherwise is False
        """
        if event == consts.UPDATE_FWG and 'last-port' in fwg:
            return not fwg['last-port']
        else:
            return bool(fwg['ports'])

    @staticmethod
    def _has_policy(fwg):
        """Verifying fwg has policy or not"""
        return bool(fwg['ingress_firewall_policy_id'] or
                    fwg['egress_firewall_policy_id'])

    def _compute_status(self, fwg, result, event=consts.CREATE_FWG):
        """Compute a status of specified firewall group for update

        Validates 'ACTIVE', 'DOWN', 'INACTIVE', 'ERROR' and None as follows:
            - "ERROR"    : if result is not True
            - "ACTIVE"   : admin_state_up is True and exists ports
            - "INACTIVE" : admin_state_up is True and with no ports
            - "DOWN"     : admin_state_up is False
            - None       : In case of 'delete_firewall_group'
        """
        if not result:
            return nl_const.ERROR

        if not fwg['admin_state_up']:
            return nl_const.DOWN

        if event == consts.DELETE_FWG:
            # This firewall_group will be deleted. No need to update status.
            return

        if (self._has_ports(fwg, event) and self._has_policy(fwg)):
            return nl_const.ACTIVE

        return nl_const.INACTIVE

    def _get_network_and_segmentation_id(self, fwg_port):
        port_id = fwg_port.get('port_id', fwg_port.get('id'))
        port_details = fwg_port.get('port_details')

        if port_details:
            target = port_details.get(port_id)
            if target:
                return target.get('network_id'), target.get('segmentation_id')
            return

        return fwg_port.get('network_id'), fwg_port.get('segmentation_id')

    def _add_local_vlan_to_ports(self, fwg_ports):
        """Add local VLAN to ports if found

        This function tries to add local VLAN related to ports.
        """

        ports_with_lvlan = []
        for fwg_port in fwg_ports:
            try:
                network_id, segm_id = self._get_network_and_segmentation_id(
                    fwg_port)
                l_vlan = self.vlan_manager.get(network_id, segm_id).vlan
                fwg_port['lvlan'] = int(l_vlan)
            except vlanmanager.MappingNotFound:
                LOG.warning("No Local VLAN found in network %s", network_id)
            # NOTE(yushiro): We ignore this exception because we should send
            # all selected ports to driver layer.  It depends on driver's
            # behavior whether it occurs an error with no local VLAN or not.
            ports_with_lvlan.append(fwg_port)

        return ports_with_lvlan

    def _apply_fwg_rules(self, fwg, ports, event=consts.UPDATE_FWG):
        """This function invokes the driver create/update routine. """
        # Set firewall group status; will be overwritten if call to driver
        # fails.
        if event in [consts.CREATE_FWG, consts.UPDATE_FWG]:
            ports_for_driver = self._add_local_vlan_to_ports(ports)
        else:
            ports_for_driver = ports

        # apply firewall group to driver
        try:
            if event == consts.UPDATE_FWG:
                self.driver.update_firewall_group(ports_for_driver, fwg)
            elif event == consts.DELETE_FWG:
                self.driver.delete_firewall_group(ports_for_driver, fwg)
            elif event == consts.CREATE_FWG:
                self.driver.create_firewall_group(ports_for_driver, fwg)
        except f_exc.FirewallInternalDriverError:
            msg = _("FWaaS driver error in %(event)s_firewall_group "
                    "for firewall group: %(fwg_id)s")
            LOG.exception(msg, {'event': event, 'fwg_id': fwg['id']})
            return False
        return True

    def _send_fwg_status(self, context, fwg_id, status, host):
        """Send firewall group's status to plugin.

        :returns: True if no exception occurred otherwise False
        :rtype: boolean
        """
        try:
            self.plugin_rpc.set_firewall_group_status(
                context, fwg_id, status, host)
            LOG.debug("Successfully sent status(%s) for firewall_group(%s)",
                      status, fwg_id)
        except Exception:
            msg = _("Failed to send status for firewall_group(%s)")
            LOG.exception(msg, fwg_id)

    def _create_firewall_group(self, context, fwg, host,
                               event=consts.CREATE_FWG):
        """Handles RPC from plugin to create a firewall group. """

        add_ports = self._get_firewall_group_ports(fwg, host)
        if not add_ports:
            status = nl_const.INACTIVE
        else:
            ret = self._apply_fwg_rules(fwg, add_ports, event)

            # cleanup port_map
            for port in add_ports:
                self.fwg_map.remove_port(port)

            status = self._compute_status(fwg, ret, event)
            for port in add_ports:
                self.fwg_map.set_port_fwg(port, fwg)
        # Update status of firewall group which is associated with ports
        # after updating.
        self._send_fwg_status(context, fwg['id'], status, host)

    def _delete_firewall_group(self, context, fwg, host,
                               event=consts.DELETE_FWG):
        """Handles RPC from plugin to delete a firewall group. """

        del_ports = self._get_firewall_group_ports(fwg, host, to_delete=True)
        if not del_ports:
            return

        # cleanup all flows of del_ports
        ret = self._apply_fwg_rules(fwg, del_ports, event=consts.DELETE_FWG)
        del_port_ids = []
        for port in del_ports:
            del_port_ids.append(port['id'])
            self.fwg_map.remove_port(port)

        if event == consts.DELETE_FWG:
            self.fwg_map.remove_fwg(fwg)
            self.plugin_rpc.firewall_group_deleted(
                context, fwg['id'], host=self.conf.host)
        else:
            status = self._compute_status(fwg, ret, event)
            self._send_fwg_status(context, fwg['id'], status, self.conf.host)

    @lockutils.synchronized('fwg')
    def create_firewall_group(self, context, firewall_group, host):
        """Handles create firewall group event"""

        # TODO(chandanc): Fix agent RPC endpoint to remove host arg
        host = cfg.CONF.host
        with self.driver.defer_apply():
            try:
                self._create_firewall_group(context, firewall_group, host)
            except Exception as exc:
                LOG.exception(
                    "Exception caught in create_firewall_group %s", exc)
                self._send_fwg_status(context, firewall_group['id'],
                                      status=nl_const.ERROR, host=host)

    @lockutils.synchronized('fwg')
    def delete_firewall_group(self, context, firewall_group, host):
        """Handles delete firewall group event"""

        # TODO(chandanc): Fix agent RPC endpoint to remove host arg
        host = cfg.CONF.host
        with self.driver.defer_apply():
            try:
                self._delete_firewall_group(context, firewall_group, host)
            except Exception as exc:
                LOG.exception(
                    "Exception caught in delete_firewall_group %s", exc)
                self._send_fwg_status(context, firewall_group['id'],
                                      status=nl_const.ERROR, host=host)

    @lockutils.synchronized('fwg')
    def update_firewall_group(self, context, firewall_group, host):
        """Handles update firewall group event"""

        # TODO(chandanc): Fix agent RPC endpoint to remove host arg
        host = cfg.CONF.host
        with self.driver.defer_apply():
            try:
                self._delete_firewall_group(
                    context, firewall_group, host, event=consts.UPDATE_FWG)
                self._create_firewall_group(
                    context, firewall_group, host, event=consts.UPDATE_FWG)
            except Exception as exc:
                LOG.exception(
                    "Exception caught in update_firewall_group %s", exc)
                self._send_fwg_status(context, firewall_group['id'],
                                      status=nl_const.ERROR, host=host)

    @lockutils.synchronized('fwg-port')
    def handle_port(self, context, port):
        """Handle port update event"""

        # Check if port is trusted and called at once.
        if nl_net.is_port_trusted(port) and not self.fwg_map.get_port(port):
            self._add_rule_for_trusted_port(port)
            self.fwg_map.set_port(port)
            return

        if not self._is_port_layer2(port):
            return

        # check if port is already assigned to a fwg
        if self.fwg_map.get_port_fwg(port):
            return

        fwg = self.plugin_rpc.get_firewall_group_for_port(
            context, port.get('port_id'))
        if not fwg:
            LOG.info("Firewall group applied to port %s is "
                     "not available on server.", port['port_id'])
            return

        ret = self._apply_fwg_rules(fwg, [port])
        status = self._compute_status(fwg, ret, event=consts.HANDLE_PORT)
        self.fwg_map.set_port_fwg(port, fwg)
        self._send_fwg_status(
            context, fwg_id=fwg['id'], status=status, host=self.conf.host)

    def _add_rule_for_trusted_port(self, port):
        self._add_local_vlan_to_ports([port])
        self.driver.process_trusted_ports([port])

    def _delete_rule_for_trusted_port(self, port):
        self.driver.remove_trusted_ports([port['port_id']])

    def delete_port(self, context, port):
        """This is being called when a port is deleted by the agent. """

        # delete_port should be handled only unbound timing for a port.
        # If 'vif_port' is included in the port dict, this is called after
        # deleted the port and should be ignored.
        if 'vif_port' in port:
            return

        port = self.fwg_map.get_port(port)

        if port and nl_net.is_port_trusted(port):
            self._delete_rule_for_trusted_port(port)
            self.fwg_map.remove_port(port)
            return

        if not self._is_port_layer2(port):
            return

        fwg = self.fwg_map.get_port_fwg(port)
        if not fwg:
            LOG.info("Firewall group associated to port %(port_id)s is "
                     "not available on server.", {'port_id': port['port_id']})
            return

        ret = self._apply_fwg_rules(fwg, [port], event=consts.DELETE_FWG)

        port_id = self.fwg_map.port_id(port)
        if port_id in fwg['ports']:
            fwg['ports'].remove(port_id)

        # update the fwg dict to known_fwgs
        self.fwg_map.set_fwg(fwg)
        self.fwg_map.remove_port(port)
        status = self._compute_status(fwg, ret, event=consts.DELETE_PORT)
        self._send_fwg_status(context, fwg['id'], status, self.conf.host)


class PortFirewallGroupMap:
    """Store relations between Port and Firewall Group and trusted port

    This map is used in deleting firewall_group because the firewall_group has
    been deleted at that time.  Therefore, it is impossible to refer 'ports'.
    This map enables to refer 'ports' for specified firewall_group.
    Furthermore, it is necessary to check 'device_owner' for trusted port, this
    Map also stores trusted port data.
    """
    def __init__(self):
        self.known_fwgs = {}
        self.port_fwg = {}
        self.port_detail = {}
        # TODO(yushiro): If agent is restarted, this map doesn't have any
        # information. Need to consider map initialization in __init__()

    def port_id(self, port):
        return (port if isinstance(port, str)
                else port.get('port_id', port.get('id')))

    def get_fwg(self, fwg_id):
        return self.known_fwgs.get(fwg_id)

    def set_fwg(self, fwg):
        self.known_fwgs[fwg['id']] = fwg

    def get_port(self, port):
        return self.port_detail.get(self.port_id(port))

    def get_port_fwg(self, port):
        fwg_id = self.port_fwg.get(self.port_id(port))
        if fwg_id:
            return self.get_fwg(fwg_id)

    def set_port(self, port):
        """Add a new port into port_detail"""
        port_id = self.port_id(port)
        self.port_detail[port_id] = port

    def set_port_fwg(self, port, fwg):
        """Add a new port into fwg['ports']"""
        port_id = self.port_id(port)
        # Update fwg['ports'] data
        fwg['ports'] = list(set(fwg['ports'] + [port_id]))
        # Update fwg_id -> firewall_group data
        self.known_fwgs[fwg['id']] = fwg
        # Update port_id -> port data
        self.port_detail[port_id] = port
        # Update port_id -> firewall_group_id relation
        self.port_fwg[port_id] = fwg['id']

    def remove_port(self, port):
        """Remove port from fwg['ports'] and port_fwg dictionary

        When removing 'port' from several cases, the port should be removed
        from this map.
        """
        port_id = self.port_id(port)
        # Check if 'port_id' has registered in port_fwg dictionary.
        # Update firewall_group
        if port_id in self.port_fwg:
            fwg_id = self.port_fwg.get(port_id)
            if not fwg_id:
                # This case is trusted port. Try to delete port_detail dict
                try:
                    del self.port_detail[port_id]
                except KeyError:
                    pass
                return
            new_fwg = self.known_fwgs[fwg_id]
            new_fwg['ports'] = [p for p in new_fwg['ports'] if p != port_id]
            self.known_fwgs[fwg_id] = new_fwg
            del self.port_fwg[port_id]
            del self.port_detail[port_id]

    def remove_fwg(self, fwg):
        """Remove firewall_group from known_fwgs dictionary

        When removing firewall_group, it should be removed from this map
        """
        if fwg['id'] in self.known_fwgs:
            del self.known_fwgs[fwg['id']]
