#
#    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 itertools
import time

from oslo_log import log as logging
from oslo_serialization import jsonutils
from oslo_utils import netutils
import tenacity

from heat.common import exception
from heat.common.i18n import _
from heat.engine import resource
from heat.engine.resources.openstack.neutron import port as neutron_port

LOG = logging.getLogger(__name__)


class ServerNetworkMixin(object):

    def _validate_network(self, network):
        net_id = network.get(self.NETWORK_ID)
        port = network.get(self.NETWORK_PORT)
        subnet = network.get(self.NETWORK_SUBNET)
        fixed_ip = network.get(self.NETWORK_FIXED_IP)
        floating_ip = network.get(self.NETWORK_FLOATING_IP)
        str_network = network.get(self.ALLOCATE_NETWORK)

        if (net_id is None and
                port is None and
                subnet is None and
                not str_network):
            msg = _('One of the properties "%(id)s", "%(port_id)s", '
                    '"%(str_network)s" or "%(subnet)s" should be set for the '
                    'specified network of server "%(server)s".'
                    '') % dict(id=self.NETWORK_ID,
                               port_id=self.NETWORK_PORT,
                               subnet=self.NETWORK_SUBNET,
                               str_network=self.ALLOCATE_NETWORK,
                               server=self.name)
            raise exception.StackValidationFailed(message=msg)
        # can not specify str_network with other keys of networks
        # at the same time
        has_value_keys = [k for k, v in network.items() if v is not None]
        if str_network and len(has_value_keys) != 1:
            msg = _('Can not specify "%s" with other keys of networks '
                    'at the same time.') % self.ALLOCATE_NETWORK
            raise exception.StackValidationFailed(message=msg)

        # Nova doesn't allow specify ip and port at the same time
        if fixed_ip and port is not None:
            raise exception.ResourcePropertyConflict(
                "/".join([self.NETWORKS, self.NETWORK_FIXED_IP]),
                "/".join([self.NETWORKS, self.NETWORK_PORT]))

        # if user only specifies network and floating ip, floating ip
        # can't be associated as the neutron port isn't created/managed
        # by heat
        if floating_ip is not None:
            if net_id is not None and port is None and subnet is None:
                msg = _('Property "%(fip)s" is not supported if only '
                        '"%(net)s" is specified, because the corresponding '
                        'port can not be retrieved.'
                        ) % dict(fip=self.NETWORK_FLOATING_IP,
                                 net=self.NETWORK_ID)
                raise exception.StackValidationFailed(message=msg)

    def _validate_belonging_subnet_to_net(self, network):
        if network.get(self.NETWORK_PORT) is None:
            net = self._get_network_id(network)
            # check if there are subnet and network both specified that
            # subnet belongs to specified network
            subnet = network.get(self.NETWORK_SUBNET)
            if (subnet is not None and net is not None):
                subnet_net = self.client_plugin(
                    'neutron').network_id_from_subnet_id(subnet)
                if subnet_net != net:
                    msg = _('Specified subnet %(subnet)s does not belongs to '
                            'network %(network)s.') % {
                        'subnet': subnet,
                        'network': net}
                    raise exception.StackValidationFailed(message=msg)

    def _create_internal_port(self, net_data, net_number,
                              security_groups=None):
        name = _('%(server)s-port-%(number)s') % {'server': self.name,
                                                  'number': net_number}

        kwargs = self._prepare_internal_port_kwargs(net_data, security_groups)
        kwargs['name'] = name

        port = self.client('neutron').create_port({'port': kwargs})['port']

        # Store ids (used for floating_ip association, updating, etc.)
        # in resource's data.
        self._data_update_ports(port['id'], 'add')

        return port['id']

    def _prepare_internal_port_kwargs(self, net_data, security_groups=None):
        kwargs = {'network_id': self._get_network_id(net_data)}
        fixed_ip = net_data.get(self.NETWORK_FIXED_IP)
        subnet = net_data.get(self.NETWORK_SUBNET)
        body = {}
        if fixed_ip:
            body['ip_address'] = fixed_ip
        if subnet:
            body['subnet_id'] = subnet
        # we should add fixed_ips only if subnet or ip were provided
        if body:
            kwargs.update({'fixed_ips': [body]})
        if net_data.get(neutron_port.Port.NO_FIXED_IPS):
            kwargs.update({'fixed_ips': []})

        if security_groups:
            sec_uuids = self.client_plugin(
                'neutron').get_secgroup_uuids(security_groups)
            kwargs['security_groups'] = sec_uuids

        extra_props = net_data.get(self.NETWORK_PORT_EXTRA)
        if extra_props is not None:
            specs = extra_props.pop(neutron_port.Port.VALUE_SPECS)
            if specs:
                kwargs.update(specs)
            port_extra_keys = list(neutron_port.Port.EXTRA_PROPERTIES)
            port_extra_keys.remove(neutron_port.Port.ALLOWED_ADDRESS_PAIRS)
            port_extra_keys.remove(neutron_port.Port.NO_FIXED_IPS)
            for key in port_extra_keys:
                if extra_props.get(key) is not None:
                    kwargs[key] = extra_props.get(key)

            allowed_address_pairs = extra_props.get(
                neutron_port.Port.ALLOWED_ADDRESS_PAIRS)
            if allowed_address_pairs is not None:
                for pair in allowed_address_pairs:
                    if (neutron_port.Port.ALLOWED_ADDRESS_PAIR_MAC_ADDRESS
                        in pair and pair.get(
                            neutron_port.Port.ALLOWED_ADDRESS_PAIR_MAC_ADDRESS)
                            is None):
                        del pair[
                            neutron_port.Port.ALLOWED_ADDRESS_PAIR_MAC_ADDRESS]
                port_address_pairs = neutron_port.Port.ALLOWED_ADDRESS_PAIRS
                kwargs[port_address_pairs] = allowed_address_pairs

        return kwargs

    def _delete_internal_port(self, port_id):
        """Delete physical port by id."""
        with self.client_plugin('neutron').ignore_not_found:
            self.client('neutron').delete_port(port_id)

        self._data_update_ports(port_id, 'delete')

    def _delete_internal_ports(self):
        for port_data in self._data_get_ports():
            self._delete_internal_port(port_data['id'])

        self.data_delete('internal_ports')

    def _data_update_ports(self, port_id, action, port_type='internal_ports'):
        data = self._data_get_ports(port_type)

        if action == 'add':
            data.append({'id': port_id})
        elif action == 'delete':
            for port in data:
                if port_id == port['id']:
                    data.remove(port)
                    break

        self.data_set(port_type, jsonutils.dumps(data))

    def _data_get_ports(self, port_type='internal_ports'):
        data = self.data().get(port_type)
        return jsonutils.loads(data) if data else []

    def store_external_ports(self):
        """Store in resource's data IDs of ports created by nova for server.

        If no port property is specified and no internal port has been created,
        nova client takes no port-id and calls port creating into server
        creating. We need to store information about that ports, so store
        their IDs to data with key `external_ports`.
        """
        server = self.client().servers.get(self.resource_id)
        ifaces = server.interface_list()
        external_port_ids = set(iface.port_id for iface in ifaces)
        # need to make sure external_ports data doesn't store ids of non-exist
        # ports. Delete such port_id if it's needed.
        data_external_port_ids = set(
            port['id'] for port in self._data_get_ports('external_ports'))
        for port_id in data_external_port_ids - external_port_ids:
            self._data_update_ports(port_id, 'delete',
                                    port_type='external_ports')

        internal_port_ids = set(port['id'] for port in self._data_get_ports())
        # add ids of new external ports which not contains in external_ports
        # data yet. Also, exclude ids of internal ports.
        new_ports = ((external_port_ids - internal_port_ids) -
                     data_external_port_ids)
        for port_id in new_ports:
            self._data_update_ports(port_id, 'add', port_type='external_ports')

    def _build_nics(self, networks, security_groups=None):
        if not networks:
            return None

        str_network = self._str_network(networks)
        if str_network:
            return str_network

        nics = []

        for idx, net in enumerate(networks):
            self._validate_belonging_subnet_to_net(net)
            nic_info = {'net-id': self._get_network_id(net)}
            if net.get(self.NETWORK_PORT):
                nic_info['port-id'] = net[self.NETWORK_PORT]
            elif net.get(self.NETWORK_SUBNET):
                nic_info['port-id'] = self._create_internal_port(
                    net, idx, security_groups)

            # if nic_info including 'port-id', do not set ip for nic
            if not nic_info.get('port-id'):
                if net.get(self.NETWORK_FIXED_IP):
                    ip = net[self.NETWORK_FIXED_IP]
                    if netutils.is_valid_ipv6(ip):
                        nic_info['v6-fixed-ip'] = ip
                    else:
                        nic_info['v4-fixed-ip'] = ip

            if net.get(self.NETWORK_FLOATING_IP) and nic_info.get('port-id'):
                floating_ip_data = {'port_id': nic_info['port-id']}
                if net.get(self.NETWORK_FIXED_IP):
                    floating_ip_data.update(
                        {'fixed_ip_address':
                            net.get(self.NETWORK_FIXED_IP)})
                self._floating_ip_neutron_associate(
                    net.get(self.NETWORK_FLOATING_IP), floating_ip_data)

            if net.get(self.NIC_TAG):
                nic_info[self.NIC_TAG] = net.get(self.NIC_TAG)

            nics.append(nic_info)
        return nics

    def _floating_ip_neutron_associate(self, floating_ip, floating_ip_data):
        self.client('neutron').update_floatingip(
            floating_ip, {'floatingip': floating_ip_data})

    def _floating_ip_disassociate(self, floating_ip):
        with self.client_plugin('neutron').ignore_not_found:
            self.client('neutron').update_floatingip(
                floating_ip, {'floatingip': {'port_id': None}})

    def _find_best_match(self, existing_interfaces, specified_net):
        specified_net_items = set(specified_net.items())
        if specified_net.get(self.NETWORK_PORT) is not None:
            for iface in existing_interfaces:
                if (iface[self.NETWORK_PORT] ==
                        specified_net[self.NETWORK_PORT] and
                        specified_net_items.issubset(set(iface.items()))):
                    return iface
        elif specified_net.get(self.NETWORK_FIXED_IP) is not None:
            for iface in existing_interfaces:
                if (iface[self.NETWORK_FIXED_IP] ==
                        specified_net[self.NETWORK_FIXED_IP] and
                        specified_net_items.issubset(set(iface.items()))):
                    return iface
        else:
            # Best subset intersection
            best, matches, num = None, 0, 0
            for iface in existing_interfaces:
                iface_items = set(iface.items())
                if specified_net_items.issubset(iface_items):
                    num = len(specified_net_items.intersection(iface_items))
                if num > matches:
                    best, matches = iface, num
            return best

    def _exclude_not_updated_networks(self, old_nets, new_nets, interfaces):
        not_updated_nets = []

        # Update old_nets to match interfaces
        self.update_networks_matching_iface_port(old_nets, interfaces)
        # make networks similar by adding None values for not used keys
        for key in self._NETWORK_KEYS:
            # if _net.get(key) is '', convert to None
            for _net in itertools.chain(new_nets, old_nets):
                _net[key] = _net.get(key) or None

        for new_net in list(new_nets):
            new_net_reduced = {k: v for k, v in new_net.items()
                               if k not in self._IFACE_MANAGED_KEYS or
                               v is not None}
            match = self._find_best_match(old_nets, new_net_reduced)
            if match is not None:
                not_updated_nets.append(match)
                new_nets.remove(new_net)
                old_nets.remove(match)

        return not_updated_nets

    def _get_network_id(self, net):
        net_id = net.get(self.NETWORK_ID) or None
        subnet = net.get(self.NETWORK_SUBNET) or None
        if not net_id and subnet:
            net_id = self.client_plugin(
                'neutron').network_id_from_subnet_id(subnet)
        return net_id

    def update_networks_matching_iface_port(self, old_nets, interfaces):

        def get_iface_props(iface):
            ipaddr = None
            subnet = None
            if len(iface.fixed_ips) > 0:
                ipaddr = iface.fixed_ips[0]['ip_address']
                subnet = iface.fixed_ips[0]['subnet_id']
            return {self.NETWORK_PORT: iface.port_id,
                    self.NETWORK_ID: iface.net_id,
                    self.NETWORK_FIXED_IP: ipaddr,
                    self.NETWORK_SUBNET: subnet}

        interfaces_net_props = [get_iface_props(iface) for iface in interfaces]
        for old_net in old_nets:
            if old_net[self.NETWORK_PORT] is None:
                old_net[self.NETWORK_ID] = self._get_network_id(old_net)
            old_net_reduced = {k: v for k, v in old_net.items()
                               if k in self._IFACE_MANAGED_KEYS and
                               v is not None}
            match = self._find_best_match(interfaces_net_props,
                                          old_net_reduced)
            if match is not None:
                old_net.update(match)
                interfaces_net_props.remove(match)

    def _get_available_networks(self):
        # first we get the private networks owned by the tenant
        search_opts = {'tenant_id': self.context.project_id, 'shared': False,
                       'admin_state_up': True, }
        nc = self.client('neutron')
        nets = nc.list_networks(**search_opts).get('networks', [])
        # second we get the public shared networks
        search_opts = {'shared': True}
        nets += nc.list_networks(**search_opts).get('networks', [])

        ids = [net['id'] for net in nets]

        return ids

    def _auto_allocate_network(self):
        topology = self.client('neutron').get_auto_allocated_topology(
            self.context.project_id)['auto_allocated_topology']

        return topology['id']

    def _calculate_using_str_network(self, ifaces, str_net,
                                     security_groups=None):
        add_nets = []
        remove_ports = [iface.port_id for iface in ifaces or []]
        if str_net == self.NETWORK_AUTO:
            nets = self._get_available_networks()
            if not nets:
                nets = [self._auto_allocate_network()]
            if len(nets) > 1:
                msg = 'Multiple possible networks found.'
                raise exception.UnableToAutoAllocateNetwork(message=msg)
            handle_args = {'port_id': None, 'net_id': nets[0], 'fip': None}
            if security_groups:
                sg_ids = self.client_plugin(
                    'neutron').get_secgroup_uuids(security_groups)
                handle_args['security_groups'] = sg_ids
            add_nets.append(handle_args)
        return remove_ports, add_nets

    def _calculate_using_list_networks(self, old_nets, new_nets, ifaces,
                                       security_groups):
        remove_ports = []
        add_nets = []
        # if update networks between None and empty, no need to
        # detach and attach, the server got first free port already.
        if not new_nets and not old_nets:
            return remove_ports, add_nets

        new_nets = new_nets or []
        old_nets = old_nets or []
        remove_ports, not_updated_nets = self._calculate_remove_ports(
            old_nets, new_nets, ifaces)
        add_nets = self._calculate_add_nets(new_nets, not_updated_nets,
                                            security_groups)

        return remove_ports, add_nets

    def _calculate_remove_ports(self, old_nets, new_nets, ifaces):
        remove_ports = []
        not_updated_nets = []
        # if old nets is empty, it means that the server got first
        # free port. so we should detach this interface.
        if not old_nets:
            for iface in ifaces:
                remove_ports.append(iface.port_id)

        # if we have any information in networks field, we should:
        # 1. find similar networks, if they exist
        # 2. remove these networks from new_nets and old_nets
        #    lists
        # 3. detach unmatched networks, which were present in old_nets
        # 4. attach unmatched networks, which were present in new_nets
        else:
            # if old net is string net, remove the interfaces
            if self._str_network(old_nets):
                remove_ports = [iface.port_id for iface in ifaces or []]
            else:
                # remove not updated networks from old and new networks lists,
                # also get list these networks
                not_updated_nets = self._exclude_not_updated_networks(
                    old_nets, new_nets, ifaces)

                # according to nova interface-detach command detached port
                # will be deleted
                inter_port_data = self._data_get_ports()
                inter_port_ids = [p['id'] for p in inter_port_data]
                for net in old_nets:
                    port_id = net.get(self.NETWORK_PORT)
                    # we can't match the port for some user case, like:
                    # the internal port was detached in nova first, then
                    # user update template to detach this nic. The internal
                    # port will remains till we delete the server resource.
                    if port_id:
                        remove_ports.append(port_id)
                        if port_id in inter_port_ids:
                            # if we have internal port with such id, remove it
                            # instantly.
                            self._delete_internal_port(port_id)
                    if net.get(self.NETWORK_FLOATING_IP):
                        self._floating_ip_disassociate(
                            net.get(self.NETWORK_FLOATING_IP))
        return remove_ports, not_updated_nets

    def _calculate_add_nets(self, new_nets, not_updated_nets,
                            security_groups):
        add_nets = []

        # if new_nets is empty (including the non_updated_nets), we should
        # attach first free port, similar to the behavior for instance
        # creation
        if not new_nets and not not_updated_nets:
            handler_kwargs = {'port_id': None, 'net_id': None, 'fip': None}
            if security_groups:
                sec_uuids = self.client_plugin(
                    'neutron').get_secgroup_uuids(security_groups)
                handler_kwargs['security_groups'] = sec_uuids
            add_nets.append(handler_kwargs)
        else:
            # attach section similar for both variants that
            # were mentioned above
            for idx, net in enumerate(new_nets):
                handler_kwargs = {'port_id': None,
                                  'net_id': None,
                                  'fip': None}

                if net.get(self.NETWORK_PORT):
                    handler_kwargs['port_id'] = net.get(self.NETWORK_PORT)
                elif net.get(self.NETWORK_SUBNET):
                    handler_kwargs['port_id'] = self._create_internal_port(
                        net, idx, security_groups)

                if not handler_kwargs['port_id']:
                    handler_kwargs['net_id'] = self._get_network_id(net)
                    if security_groups:
                        sec_uuids = self.client_plugin(
                            'neutron').get_secgroup_uuids(security_groups)
                        handler_kwargs['security_groups'] = sec_uuids
                if handler_kwargs['net_id']:
                    handler_kwargs['fip'] = net.get('fixed_ip')

                floating_ip = net.get(self.NETWORK_FLOATING_IP)
                if floating_ip:
                    flip_associate = {'port_id': handler_kwargs.get('port_id')}
                    if net.get('fixed_ip'):
                        flip_associate['fixed_ip_address'] = net.get(
                            'fixed_ip')

                    self.update_floating_ip_association(floating_ip,
                                                        flip_associate)

                add_nets.append(handler_kwargs)

        return add_nets

    def _str_network(self, networks):
        # if user specify 'allocate_network', return it
        # otherwise we return None
        for net in networks or []:
            str_net = net.get(self.ALLOCATE_NETWORK)
            if str_net:
                return str_net

    def _is_nic_tagged(self, networks):
        # if user specify 'tag', return True
        # otherwise return False
        for net in networks or []:
            if net.get(self.NIC_TAG):
                return True

        return False

    def calculate_networks(self, old_nets, new_nets, ifaces,
                           security_groups=None):
        new_str_net = self._str_network(new_nets)
        if new_str_net:
            return self._calculate_using_str_network(ifaces, new_str_net,
                                                     security_groups)
        else:
            return self._calculate_using_list_networks(
                old_nets, new_nets, ifaces, security_groups)

    def update_floating_ip_association(self, floating_ip, flip_associate):
        if flip_associate.get('port_id'):
            self._floating_ip_neutron_associate(floating_ip, flip_associate)

    @staticmethod
    def get_all_ports(server):
        return itertools.chain(
            server._data_get_ports(),
            server._data_get_ports('external_ports')
        )

    def detach_ports(self, server):
        existing_server_id = server.resource_id
        for port in self.get_all_ports(server):
            detach_called = self.client_plugin().interface_detach(
                existing_server_id, port['id'])

            if not detach_called:
                return

            try:
                if self.client_plugin().check_interface_detach(
                        existing_server_id, port['id']):
                    LOG.info('Detach interface %(port)s successful from '
                             'server %(server)s.',
                             {'port': port['id'],
                              'server': existing_server_id})
            except tenacity.RetryError:
                raise exception.InterfaceDetachFailed(
                    port=port['id'], server=existing_server_id)

    def attach_ports(self, server):
        prev_server_id = server.resource_id

        for port in self.get_all_ports(server):
            self.client_plugin().interface_attach(prev_server_id,
                                                  port['id'])
            try:
                if self.client_plugin().check_interface_attach(
                        prev_server_id, port['id']):
                    LOG.info('Attach interface %(port)s successful to '
                             'server %(server)s',
                             {'port': port['id'],
                              'server': prev_server_id})
            except tenacity.RetryError:
                raise exception.InterfaceAttachFailed(
                    port=port['id'], server=prev_server_id)

    def prepare_ports_for_replace(self):
        # Check that the interface can be detached
        server = None
        # TODO(TheJulia): Once Story #2002001 is underway,
        # we should be able to replace the query to nova and
        # the check for the failed status with just a check
        # to see if the resource has failed.
        with self.client_plugin().ignore_not_found:
            server = self.client().servers.get(self.resource_id)
        if server and server.status != 'ERROR':
            self.detach_ports(self)
        else:
            # If we are replacing an ERROR'ed node, we need to delete
            # internal ports that we have created, otherwise we can
            # encounter deployment issues with duplicate internal
            # port data attempting to be created in instances being
            # deployed.
            self._delete_internal_ports()

    def restore_ports_after_rollback(self, convergence):
        # In case of convergence, during rollback, the previous rsrc is
        # already selected and is being acted upon.
        if convergence:
            prev_server = self
            rsrc, rsrc_owning_stack, stack = resource.Resource.load(
                prev_server.context, prev_server.replaced_by,
                prev_server.stack.current_traversal, True,
                prev_server.stack.defn._resource_data
            )
            existing_server = rsrc
        else:
            backup_stack = self.stack._backup_stack()
            prev_server = backup_stack.resources.get(self.name)
            existing_server = self

        # Wait until server will move to active state. We can't
        # detach interfaces from server in BUILDING state.
        # In case of convergence, the replacement resource may be
        # created but never have been worked on because the rollback was
        # trigerred or new update was trigerred.
        if existing_server.resource_id is not None:
            try:
                while True:
                    active = self.client_plugin()._check_active(
                        existing_server.resource_id)
                    if active:
                        break
                    time.sleep(1)
            except exception.ResourceInError:
                pass

            self.store_external_ports()
            self.detach_ports(existing_server)

        self.attach_ports(prev_server)
