# Copyright (c) 2015 Infoblox 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 netaddr
from oslo_db import exception as db_exc
from oslo_log import log as logging
from oslo_utils import excutils
from sqlalchemy import and_

from neutron._i18n import _, _LE
from neutron.api.v2 import attributes
from neutron.common import constants
from neutron.common import exceptions as n_exc
from neutron.common import ipv6_utils
from neutron.db import ipam_backend_mixin
from neutron.db import models_v2
from neutron.ipam import driver
from neutron.ipam import exceptions as ipam_exc
from neutron.ipam import requests as ipam_req


LOG = logging.getLogger(__name__)


class IpamPluggableBackend(ipam_backend_mixin.IpamBackendMixin):

    def _get_failed_ips(self, all_ips, success_ips):
        ips_list = (ip_dict['ip_address'] for ip_dict in success_ips)
        return (ip_dict['ip_address'] for ip_dict in all_ips
                if ip_dict['ip_address'] not in ips_list)

    def _ipam_deallocate_ips(self, context, ipam_driver, port, ips,
                             revert_on_fail=True):
        """Deallocate set of ips over IPAM.

        If any single ip deallocation fails, tries to allocate deallocated
        ip addresses with fixed ip request
        """
        deallocated = []

        try:
            for ip in ips:
                try:
                    ipam_subnet = ipam_driver.get_subnet(ip['subnet_id'])
                    ipam_subnet.deallocate(ip['ip_address'])
                    deallocated.append(ip)
                except n_exc.SubnetNotFound:
                    LOG.debug("Subnet was not found on ip deallocation: %s",
                              ip)
        except Exception:
            with excutils.save_and_reraise_exception():
                LOG.debug("An exception occurred during IP deallocation.")
                if revert_on_fail and deallocated:
                    LOG.debug("Reverting deallocation")
                    self._ipam_allocate_ips(context, ipam_driver, port,
                                            deallocated, revert_on_fail=False)
                elif not revert_on_fail and ips:
                    addresses = ', '.join(self._get_failed_ips(ips,
                                                               deallocated))
                    LOG.error(_LE("IP deallocation failed on "
                                  "external system for %s"), addresses)
        return deallocated

    def _ipam_try_allocate_ip(self, context, ipam_driver, port, ip_dict):
        factory = ipam_driver.get_address_request_factory()
        ip_request = factory.get_request(context, port, ip_dict)
        ipam_subnet = ipam_driver.get_subnet(ip_dict['subnet_id'])
        return ipam_subnet.allocate(ip_request)

    def _ipam_allocate_single_ip(self, context, ipam_driver, port, subnets):
        """Allocates single ip from set of subnets

        Raises n_exc.IpAddressGenerationFailure if allocation failed for
        all subnets.
        """
        for subnet in subnets:
            try:
                return [self._ipam_try_allocate_ip(context, ipam_driver,
                                                   port, subnet),
                        subnet]
            except ipam_exc.IpAddressGenerationFailure:
                continue
        raise n_exc.IpAddressGenerationFailure(
            net_id=port['network_id'])

    def _ipam_allocate_ips(self, context, ipam_driver, port, ips,
                           revert_on_fail=True):
        """Allocate set of ips over IPAM.

        If any single ip allocation fails, tries to deallocate all
        allocated ip addresses.
        """
        allocated = []

        # we need to start with entries that asked for a specific IP in case
        # those IPs happen to be next in the line for allocation for ones that
        # didn't ask for a specific IP
        ips.sort(key=lambda x: 'ip_address' not in x)
        try:
            for ip in ips:
                # By default IP info is dict, used to allocate single ip
                # from single subnet.
                # IP info can be list, used to allocate single ip from
                # multiple subnets (i.e. first successful ip allocation
                # is returned)
                ip_list = [ip] if isinstance(ip, dict) else ip
                ip_address, ip_subnet = self._ipam_allocate_single_ip(
                    context, ipam_driver, port, ip_list)
                allocated.append({'ip_address': ip_address,
                                  'subnet_id': ip_subnet['subnet_id']})
        except Exception:
            with excutils.save_and_reraise_exception():
                LOG.debug("An exception occurred during IP allocation.")

                if revert_on_fail and allocated:
                    LOG.debug("Reverting allocation")
                    self._ipam_deallocate_ips(context, ipam_driver, port,
                                              allocated, revert_on_fail=False)
                elif not revert_on_fail and ips:
                    addresses = ', '.join(self._get_failed_ips(ips,
                                                               allocated))
                    LOG.error(_LE("IP allocation failed on "
                                  "external system for %s"), addresses)

        return allocated

    def _ipam_update_allocation_pools(self, context, ipam_driver, subnet):
        self.validate_allocation_pools(subnet['allocation_pools'],
                                       subnet['cidr'])

        factory = ipam_driver.get_subnet_request_factory()
        subnet_request = factory.get_request(context, subnet, None)

        ipam_driver.update_subnet(subnet_request)

    def delete_subnet(self, context, subnet_id):
        ipam_driver = driver.Pool.get_instance(None, context)
        ipam_driver.remove_subnet(subnet_id)

    def allocate_ips_for_port_and_store(self, context, port, port_id):
        # Make a copy of port dict to prevent changing
        # incoming dict by adding 'id' to it.
        # Deepcopy doesn't work correctly in this case, because copy of
        # ATTR_NOT_SPECIFIED object happens. Address of copied object doesn't
        # match original object, so 'is' check fails
        port_copy = {'port': port['port'].copy()}
        port_copy['port']['id'] = port_id
        network_id = port_copy['port']['network_id']
        ips = []
        try:
            ips = self._allocate_ips_for_port(context, port_copy)
            for ip in ips:
                ip_address = ip['ip_address']
                subnet_id = ip['subnet_id']
                IpamPluggableBackend._store_ip_allocation(
                    context, ip_address, network_id,
                    subnet_id, port_id)
            return ips
        except Exception:
            with excutils.save_and_reraise_exception():
                if ips:
                    LOG.debug("An exception occurred during port creation. "
                              "Reverting IP allocation")
                    ipam_driver = driver.Pool.get_instance(None, context)
                    self._ipam_deallocate_ips(context, ipam_driver,
                                              port_copy['port'], ips,
                                              revert_on_fail=False)

    def _allocate_ips_for_port(self, context, port):
        """Allocate IP addresses for the port. IPAM version.

        If port['fixed_ips'] is set to 'ATTR_NOT_SPECIFIED', allocate IP
        addresses for the port. If port['fixed_ips'] contains an IP address or
        a subnet_id then allocate an IP address accordingly.
        """
        p = port['port']
        ips = []
        v6_stateless = []
        net_id_filter = {'network_id': [p['network_id']]}
        subnets = self._get_subnets(context, filters=net_id_filter)
        is_router_port = (
            p['device_owner'] in constants.ROUTER_INTERFACE_OWNERS_SNAT)

        fixed_configured = p['fixed_ips'] is not attributes.ATTR_NOT_SPECIFIED
        if fixed_configured:
            ips = self._test_fixed_ips_for_port(context,
                                                p["network_id"],
                                                p['fixed_ips'],
                                                p['device_owner'])
            # For ports that are not router ports, implicitly include all
            # auto-address subnets for address association.
            if not is_router_port:
                v6_stateless += [subnet for subnet in subnets
                                 if ipv6_utils.is_auto_address_subnet(subnet)]
        else:
            # Split into v4, v6 stateless and v6 stateful subnets
            v4 = []
            v6_stateful = []
            for subnet in subnets:
                if subnet['ip_version'] == 4:
                    v4.append(subnet)
                else:
                    if ipv6_utils.is_auto_address_subnet(subnet):
                        if not is_router_port:
                            v6_stateless.append(subnet)
                    else:
                        v6_stateful.append(subnet)

            version_subnets = [v4, v6_stateful]
            for subnets in version_subnets:
                if subnets:
                    ips.append([{'subnet_id': s['id']}
                                for s in subnets])

        for subnet in v6_stateless:
            # IP addresses for IPv6 SLAAC and DHCPv6-stateless subnets
            # are implicitly included.
            ips.append({'subnet_id': subnet['id'],
                        'subnet_cidr': subnet['cidr'],
                        'eui64_address': True,
                        'mac': p['mac_address']})
        ipam_driver = driver.Pool.get_instance(None, context)
        return self._ipam_allocate_ips(context, ipam_driver, p, ips)

    def _test_fixed_ips_for_port(self, context, network_id, fixed_ips,
                                 device_owner):
        """Test fixed IPs for port.

        Check that configured subnets are valid prior to allocating any
        IPs. Include the subnet_id in the result if only an IP address is
        configured.

        :raises: InvalidInput, IpAddressInUse, InvalidIpForNetwork,
                 InvalidIpForSubnet
        """
        fixed_ip_list = []
        for fixed in fixed_ips:
            subnet = self._get_subnet_for_fixed_ip(context, fixed, network_id)

            is_auto_addr_subnet = ipv6_utils.is_auto_address_subnet(subnet)
            if 'ip_address' in fixed:
                if (is_auto_addr_subnet and device_owner not in
                        constants.ROUTER_INTERFACE_OWNERS):
                    msg = (_("IPv6 address %(address)s can not be directly "
                            "assigned to a port on subnet %(id)s since the "
                            "subnet is configured for automatic addresses") %
                           {'address': fixed['ip_address'],
                            'id': subnet['id']})
                    raise n_exc.InvalidInput(error_message=msg)
                fixed_ip_list.append({'subnet_id': subnet['id'],
                                      'ip_address': fixed['ip_address']})
            else:
                # A scan for auto-address subnets on the network is done
                # separately so that all such subnets (not just those
                # listed explicitly here by subnet ID) are associated
                # with the port.
                if (device_owner in constants.ROUTER_INTERFACE_OWNERS_SNAT or
                        not is_auto_addr_subnet):
                    fixed_ip_list.append({'subnet_id': subnet['id']})

        self._validate_max_ips_per_port(fixed_ip_list, device_owner)
        return fixed_ip_list

    def _update_ips_for_port(self, context, port,
                             original_ips, new_ips, mac):
        """Add or remove IPs from the port. IPAM version"""
        added = []
        removed = []
        changes = self._get_changed_ips_for_port(
            context, original_ips, new_ips, port['device_owner'])
        # Check if the IP's to add are OK
        to_add = self._test_fixed_ips_for_port(
            context, port['network_id'], changes.add,
            port['device_owner'])

        ipam_driver = driver.Pool.get_instance(None, context)
        if changes.remove:
            removed = self._ipam_deallocate_ips(context, ipam_driver, port,
                                                changes.remove)
        if to_add:
            added = self._ipam_allocate_ips(context, ipam_driver,
                                            port, to_add)
        return self.Changes(add=added,
                            original=changes.original,
                            remove=removed)

    def save_allocation_pools(self, context, subnet, allocation_pools):
        for pool in allocation_pools:
            first_ip = str(netaddr.IPAddress(pool.first, pool.version))
            last_ip = str(netaddr.IPAddress(pool.last, pool.version))
            ip_pool = models_v2.IPAllocationPool(subnet=subnet,
                                                 first_ip=first_ip,
                                                 last_ip=last_ip)
            context.session.add(ip_pool)

    def update_port_with_ips(self, context, db_port, new_port, new_mac):
        changes = self.Changes(add=[], original=[], remove=[])

        if 'fixed_ips' in new_port:
            original = self._make_port_dict(db_port,
                                            process_extensions=False)
            changes = self._update_ips_for_port(context,
                                                db_port,
                                                original["fixed_ips"],
                                                new_port['fixed_ips'],
                                                new_mac)
        try:
            # Check if the IPs need to be updated
            network_id = db_port['network_id']
            for ip in changes.add:
                self._store_ip_allocation(
                    context, ip['ip_address'], network_id,
                    ip['subnet_id'], db_port.id)
            for ip in changes.remove:
                self._delete_ip_allocation(context, network_id,
                                           ip['subnet_id'], ip['ip_address'])
            self._update_db_port(context, db_port, new_port, network_id,
                                 new_mac)
        except Exception:
            with excutils.save_and_reraise_exception():
                if 'fixed_ips' in new_port:
                    LOG.debug("An exception occurred during port update.")
                    ipam_driver = driver.Pool.get_instance(None, context)
                    if changes.add:
                        LOG.debug("Reverting IP allocation.")
                        self._ipam_deallocate_ips(context, ipam_driver,
                                                  db_port, changes.add,
                                                  revert_on_fail=False)
                    if changes.remove:
                        LOG.debug("Reverting IP deallocation.")
                        self._ipam_allocate_ips(context, ipam_driver,
                                                db_port, changes.remove,
                                                revert_on_fail=False)
        return changes

    def delete_port(self, context, id):
        # Get fixed_ips list before port deletion
        port = self._get_port(context, id)
        ipam_driver = driver.Pool.get_instance(None, context)

        super(IpamPluggableBackend, self).delete_port(context, id)
        # Deallocating ips via IPAM after port is deleted locally.
        # So no need to do rollback actions on remote server
        # in case of fail to delete port locally
        self._ipam_deallocate_ips(context, ipam_driver, port,
                                  port['fixed_ips'])

    def update_db_subnet(self, context, id, s, old_pools):
        ipam_driver = driver.Pool.get_instance(None, context)
        if "allocation_pools" in s:
            self._ipam_update_allocation_pools(context, ipam_driver, s)

        try:
            subnet, changes = super(IpamPluggableBackend,
                                    self).update_db_subnet(context, id,
                                                           s, old_pools)
        except Exception:
            with excutils.save_and_reraise_exception():
                if "allocation_pools" in s and old_pools:
                    LOG.error(
                        _LE("An exception occurred during subnet update. "
                            "Reverting allocation pool changes"))
                    s['allocation_pools'] = old_pools
                    self._ipam_update_allocation_pools(context, ipam_driver, s)
        return subnet, changes

    def add_auto_addrs_on_network_ports(self, context, subnet, ipam_subnet):
        """For an auto-address subnet, add addrs for ports on the net."""
        with context.session.begin(subtransactions=True):
            network_id = subnet['network_id']
            port_qry = context.session.query(models_v2.Port)
            ports = port_qry.filter(
                and_(models_v2.Port.network_id == network_id,
                     ~models_v2.Port.device_owner.in_(
                         constants.ROUTER_INTERFACE_OWNERS_SNAT)))
            updated_ports = []
            for port in ports:
                ip_request = ipam_req.AutomaticAddressRequest(
                    prefix=subnet['cidr'],
                    mac=port['mac_address'])
                ip_address = ipam_subnet.allocate(ip_request)
                allocated = models_v2.IPAllocation(network_id=network_id,
                                                   port_id=port['id'],
                                                   ip_address=ip_address,
                                                   subnet_id=subnet['id'])
                try:
                    # Do the insertion of each IP allocation entry within
                    # the context of a nested transaction, so that the entry
                    # is rolled back independently of other entries whenever
                    # the corresponding port has been deleted.
                    with context.session.begin_nested():
                        context.session.add(allocated)
                    updated_ports.append(port['id'])
                except db_exc.DBReferenceError:
                    LOG.debug("Port %s was deleted while updating it with an "
                              "IPv6 auto-address. Ignoring.", port['id'])
                    LOG.debug("Reverting IP allocation for %s", ip_address)
                    # Do not fail if reverting allocation was unsuccessful
                    try:
                        ipam_subnet.deallocate(ip_address)
                    except Exception:
                        LOG.debug("Reverting IP allocation failed for %s",
                                  ip_address)
            return updated_ports

    def allocate_subnet(self, context, network, subnet, subnetpool_id):
        subnetpool = None

        if subnetpool_id and not subnetpool_id == constants.IPV6_PD_POOL_ID:
            subnetpool = self._get_subnetpool(context, subnetpool_id)
            self._validate_ip_version_with_subnetpool(subnet, subnetpool)

        # gateway_ip and allocation pools should be validated or generated
        # only for specific request
        if subnet['cidr'] is not attributes.ATTR_NOT_SPECIFIED:
            subnet['gateway_ip'] = self._gateway_ip_str(subnet,
                                                        subnet['cidr'])
            subnet['allocation_pools'] = self._prepare_allocation_pools(
                subnet['allocation_pools'],
                subnet['cidr'],
                subnet['gateway_ip'])

        ipam_driver = driver.Pool.get_instance(subnetpool, context)
        subnet_factory = ipam_driver.get_subnet_request_factory()
        subnet_request = subnet_factory.get_request(context, subnet,
                                                    subnetpool)
        ipam_subnet = ipam_driver.allocate_subnet(subnet_request)
        # get updated details with actually allocated subnet
        subnet_request = ipam_subnet.get_details()

        try:
            subnet = self._save_subnet(context,
                                       network,
                                       self._make_subnet_args(
                                           subnet_request,
                                           subnet,
                                           subnetpool_id),
                                       subnet['dns_nameservers'],
                                       subnet['host_routes'],
                                       subnet_request)
        except Exception:
            # Note(pbondar): Third-party ipam servers can't rely
            # on transaction rollback, so explicit rollback call needed.
            # IPAM part rolled back in exception handling
            # and subnet part is rolled back by transaction rollback.
            with excutils.save_and_reraise_exception():
                LOG.debug("An exception occurred during subnet creation. "
                          "Reverting subnet allocation.")
                self.delete_subnet(context, subnet_request.subnet_id)
        return subnet, ipam_subnet
