# Copyright (c) 2017 OpenStack Foundation
#
# 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 json
import os

from neutron_lib import constants as n_const
from neutron_lib.services.trunk import constants as t_const
from oslo_log import log as logging

from networking_arista.common import constants as a_const
from networking_arista.common import db_lib
from networking_arista.common import exceptions as arista_exc
from networking_arista.common import utils


LOG = logging.getLogger(__name__)


class AttributeFormatter(object):
    """Formats a single attribute of the CVX model based on the neutron model

    There are 4 elements to an AttributeFormatter:
    1. neutron_key - name of the key in the neutron model
    2. cvx_key - name of the key in the cvx model
    3. format(optional) - function to alter the value to be CVX compatible
    4. submodel(optional) - If the get_db_resources function queries multiple
       models, the name of the model that contains the neutron_key must be
       specified
    """

    def __init__(self, neutron_key, cvx_key, format=None, submodel=None):
        self.neutron_key = neutron_key
        self.cvx_key = cvx_key
        self.format = format or (
            lambda neutron_val, base_resource: neutron_val)
        self.submodel = submodel

    def transform(self, resource):
        base_resource = resource
        if self.submodel:
            resource = getattr(resource, self.submodel)
        return (self.cvx_key, self.format(resource[self.neutron_key],
                                          base_resource))


class AristaResourcesBase(object):
    """Tracks state of resources of one resource type on neutron and CVX

    An AristaResources class is responsible for:
    - tracking resources that have been provisioned in neutron
    - tracking resources that have been provisioned on CVX
    - creating and deleting resources on CVX to bring it in line with neutron
    - formatting neutron resources to be compatible with CVX's API
    - tracking the correct endpoint for CVX API calls

    In order to facilitate this each resource type should define:
    1. formatter - a list of AttributeFormatters to convert neutron attributes
       to models compatible with CVX's API
    2. id_key - the key in the CVX model that uniquely identifies the resource
    3. endpoint - format string for region resource endpoint
    4. get_db_resources - function that queries the neutron db for all
       resources of the resource type in question
    """

    formatter = [AttributeFormatter('id', 'id')]
    id_key = 'id'
    endpoint = 'region/%(region)s'

    def __init__(self, rpc):
        self.region = rpc.region
        self.rpc = rpc
        self.cvx_data_stale = True
        self.neutron_data_stale = True
        self.cvx_ids = set()
        self.neutron_resources = dict()

    def clear_cvx_data(self):
        self.cvx_data_stale = True
        self.cvx_ids = set()

    def clear_neutron_data(self):
        self.neutron_data_stale = True
        self.neutron_resources = dict()

    def clear_all_data(self):
        self.clear_cvx_data()
        self.clear_neutron_data()

    def update_neutron_resource(self, id, action):
        LOG.debug("%(pid)s Requesting %(action)s %(class)s resource %(id)s",
                  {'action': action, 'class': self.__class__.__name__,
                   'id': id, 'pid': os.getpid()})
        resource = self.get_db_resources(id)
        if action == a_const.FULL_SYNC:
            self.clear_neutron_data()
            LOG.info("%(pid)s Full sync resource %(class)s",
                     {'class': self.__class__.__name__,
                      'pid': os.getpid()})
            return

        assert(len(resource) <= 1)
        if resource:
            # Until we start using etcd, we need to unconditionally send the
            # create request because it might have been delete by another
            # worker.  We force this by removing the resource to our 'view' of
            # cvx resources
            self.force_resource_update(id)
            LOG.debug("%(pid)s Resource %(class)s %(id)s found, creating",
                      {'class': self.__class__.__name__, 'id': id,
                       'pid': os.getpid()})
            self._add_neutron_resource(resource[0])
        else:
            LOG.debug("%(pid)s Resource %(class)s %(id)s not found, deleting",
                      {'class': self.__class__.__name__, 'id': id,
                       'pid': os.getpid()})
            self._delete_neutron_resource(id)

    def _add_neutron_resource(self, resource):
        formatted_resource = self.format_for_create(resource)
        resource_id = list(formatted_resource.keys())[0]
        LOG.debug("%(pid)s %(class)s resource %(id)s added locally",
                  {'class': self.__class__.__name__,
                   'id': resource_id,
                   'pid': os.getpid()})
        # If the resource has changed, force a POST to CVX
        old_resource = self.neutron_resources.get(resource_id)
        if old_resource and old_resource != formatted_resource[resource_id]:
            LOG.debug("%(pid)s %(class)s resource %(id)s requires update",
                      {'class': self.__class__.__name__,
                       'id': resource_id,
                       'pid': os.getpid()})
            self.force_resource_update(resource_id)
        self.neutron_resources.update(formatted_resource)

    def force_resource_update(self, id):
        self.cvx_ids.discard(id)

    def _delete_neutron_resource(self, id):
        # Until we start using etcd, we need to unconditionally send the
        # delete request because it might have been created by another worker.
        # We force this by adding the resource to our 'view' of cvx resources
        self.cvx_ids.add(id)
        try:
            del self.neutron_resources[id]
            LOG.debug("%(pid)s %(class)s resource %(id)s removed locally",
                      {'class': self.__class__.__name__, 'id': id,
                       'pid': os.getpid()})
        except KeyError:
            LOG.debug("Resource ID %(id)s already deleted locally", {'id': id})

    def get_endpoint(self):
        return self.endpoint % {'region': self.region}

    @classmethod
    def get_resource_ids(cls, resource):
        return set([resource[cls.id_key]])

    def get_cvx_ids(self):
        if self.cvx_data_stale:
            LOG.info("%(pid)s Getting %(class)s from CVX",
                     {'class': self.__class__.__name__,
                      'pid': os.getpid()})
            cvx_data = self.rpc.send_api_request(self.get_endpoint(), 'GET')
            for resource in cvx_data:
                self.cvx_ids |= self.get_resource_ids(resource)
            self.cvx_data_stale = False
        return self.cvx_ids

    @staticmethod
    def get_db_resources(key=None):
        raise NotImplementedError

    def get_neutron_ids(self):
        if self.neutron_data_stale:
            self.get_neutron_resources()
        return set(self.neutron_resources.keys())

    def get_neutron_resources(self):
        if self.neutron_data_stale:
            LOG.info("%(pid)s Getting %(class)s from neutron",
                     {'class': self.__class__.__name__,
                      'pid': os.getpid()})
            for resource in self.get_db_resources():
                self._add_neutron_resource(resource)
            self.neutron_data_stale = False
        return self.neutron_resources

    def resource_ids_to_delete(self):
        cvx_resource_ids = self.get_cvx_ids()
        neutron_resource_ids = self.get_neutron_ids()
        return (cvx_resource_ids - neutron_resource_ids)

    def resource_ids_to_create(self):
        cvx_resource_ids = self.get_cvx_ids()
        neutron_resource_ids = self.get_neutron_ids()
        return (neutron_resource_ids - cvx_resource_ids)

    @classmethod
    def format_for_create(cls, neutron_resource):
        cvx_resource = dict(
            attr.transform(neutron_resource) for attr in cls.formatter
        )
        return {cvx_resource[cls.id_key]: cvx_resource}

    @classmethod
    def format_for_delete(cls, id):
        return {cls.id_key: id}

    def create_cvx_resources(self):
        resource_ids_to_create = self.resource_ids_to_create()
        neutron_resources = self.get_neutron_resources()
        resources_to_create = list(neutron_resources[resource_id] for
                                   resource_id in resource_ids_to_create)
        if resources_to_create:
            LOG.info("%(pid)s Creating %(class)s resources with ids %(ids)s "
                     "on CVX",
                     {'class': self.__class__.__name__,
                      'ids': ', '.join(str(r) for r in resource_ids_to_create),
                      'pid': os.getpid()})
            self.rpc.send_api_request(self.get_endpoint(), 'POST',
                                      resources_to_create)
            self.cvx_ids.update(resource_ids_to_create)
            LOG.info("%(pid)s %(class)s resources with ids %(ids)s created "
                     "on CVX",
                     {'class': self.__class__.__name__,
                      'ids': ', '.join(str(r) for r in resource_ids_to_create),
                      'pid': os.getpid()})
        else:
            LOG.debug("%(pid)s No %(class)s resources to create",
                      {'class': self.__class__.__name__,
                       'pid': os.getpid()})
        return resources_to_create

    def delete_cvx_resources(self):
        resource_ids_to_delete = self.resource_ids_to_delete()
        resources_to_delete = list(self.format_for_delete(id) for id in
                                   resource_ids_to_delete)
        if resources_to_delete:
            LOG.info("%(pid)s Deleting %(class)s resources with ids %(ids)s "
                     "from CVX",
                     {'class': self.__class__.__name__,
                      'ids': ', '.join(str(r) for r in resource_ids_to_delete),
                      'pid': os.getpid()})
            try:
                self.rpc.send_api_request(self.get_endpoint(), 'DELETE',
                                          resources_to_delete)
            except arista_exc.AristaRpcError as err:
                if not err.msg.startswith('Unknown port id'):
                    raise
            self.cvx_ids -= resource_ids_to_delete
            LOG.info("%(pid)s %(class)s resources with ids %(ids)s deleted "
                     "from CVX",
                     {'class': self.__class__.__name__,
                      'ids': ', '.join(str(r) for r in resource_ids_to_delete),
                      'pid': os.getpid()})
        else:
            LOG.debug("%(pid)s No %(class)s resources to delete",
                      {'class': self.__class__.__name__,
                       'pid': os.getpid()})
        return resources_to_delete


class Tenants(AristaResourcesBase):

    endpoint = 'region/%(region)s/tenant'
    formatter = [AttributeFormatter('project_id', 'id')]
    get_db_resources = staticmethod(db_lib.get_tenants)


class Networks(AristaResourcesBase):

    def _is_shared(rbac_entries, resource):
        for entry in rbac_entries:
            if (entry.action == 'access_as_shared' and
                    entry.target_tenant == '*'):
                return True
        return False

    def modify_blank_project_id(project_id, resource):
        if len(project_id) == 0:
            l3ha_network = getattr(resource, 'L3HARouterNetwork')
            if l3ha_network:
                project_id = l3ha_network.project_id
        return project_id

    formatter = [AttributeFormatter('id', 'id',
                                    submodel='Network'),
                 AttributeFormatter('project_id', 'tenantId',
                                    modify_blank_project_id,
                                    submodel='Network'),
                 AttributeFormatter('name', 'name',
                                    submodel='Network'),
                 AttributeFormatter('rbac_entries', 'shared', _is_shared,
                                    submodel='Network')]
    endpoint = 'region/%(region)s/network'
    get_db_resources = staticmethod(db_lib.get_networks)


class Segments(AristaResourcesBase):

    formatter = [AttributeFormatter('id', 'id'),
                 AttributeFormatter('network_type', 'type'),
                 AttributeFormatter('segmentation_id', 'segmentationId'),
                 AttributeFormatter('network_id', 'networkId'),
                 AttributeFormatter('is_dynamic', 'segmentType',
                                    lambda n, r: 'dynamic' if n else 'static')]
    endpoint = 'region/%(region)s/segment'
    get_db_resources = staticmethod(db_lib.get_segments)


class Dhcps(AristaResourcesBase):

    formatter = [AttributeFormatter('device_id', 'id',
                                    submodel='Port'),
                 AttributeFormatter('host', 'hostId',
                                    utils.hostname,
                                    submodel='PortBinding'),
                 AttributeFormatter('project_id', 'tenantId',
                                    submodel='Port')]
    endpoint = 'region/%(region)s/dhcp'
    get_db_resources = staticmethod(db_lib.get_dhcp_instances)


class Routers(AristaResourcesBase):

    def modify_blank_project_id(project_id, resource):
        if len(project_id) == 0:
            l3ha_router = getattr(resource, 'Router')
            if l3ha_router:
                project_id = l3ha_router.project_id
        return project_id

    formatter = [AttributeFormatter('device_id', 'id',
                                    submodel='Port'),
                 AttributeFormatter('device_owner', 'hostId',
                                    lambda *args: '(see router ports)',
                                    submodel='Port'),
                 AttributeFormatter('project_id', 'tenantId',
                                    modify_blank_project_id,
                                    submodel='Port')]
    endpoint = 'region/%(region)s/router'
    get_db_resources = staticmethod(db_lib.get_router_instances)


class Vms(AristaResourcesBase):

    formatter = [AttributeFormatter('device_id', 'id',
                                    submodel='Port'),
                 AttributeFormatter('host', 'hostId',
                                    utils.hostname,
                                    submodel='PortBinding'),
                 AttributeFormatter('project_id', 'tenantId',
                                    submodel='Port')]
    endpoint = 'region/%(region)s/vm'
    get_db_resources = staticmethod(db_lib.get_vm_instances)


class Baremetals(AristaResourcesBase):

    formatter = [AttributeFormatter('device_id', 'id',
                                    submodel='Port'),
                 AttributeFormatter('host', 'hostId',
                                    submodel='PortBinding'),
                 AttributeFormatter('project_id', 'tenantId',
                                    submodel='Port')]
    endpoint = 'region/%(region)s/baremetal'
    get_db_resources = staticmethod(db_lib.get_baremetal_instances)


class DhcpPorts(AristaResourcesBase):

    endpoint = 'region/%(region)s/port?type=dhcp'
    formatter = [AttributeFormatter('id', 'id', submodel='Port'),
                 AttributeFormatter('name', 'portName', submodel='Port'),
                 AttributeFormatter('device_owner', 'vlanType',
                                    lambda *args: 'allowed',
                                    submodel='Port'),
                 AttributeFormatter('network_id', 'networkId',
                                    submodel='Port'),
                 AttributeFormatter('device_id', 'instanceId',
                                    submodel='Port'),
                 AttributeFormatter('device_owner', 'instanceType',
                                    lambda *args: 'dhcp',
                                    submodel='Port'),
                 AttributeFormatter('project_id', 'tenantId',
                                    submodel='Port')]
    get_db_resources = staticmethod(db_lib.get_dhcp_ports)


class RouterPorts(AristaResourcesBase):

    def modify_blank_project_id(project_id, resource):
        if len(project_id) == 0:
            l3ha_router = getattr(resource, 'Router')
            if l3ha_router:
                project_id = l3ha_router.project_id
        return project_id

    endpoint = 'region/%(region)s/port?type=router'
    formatter = [AttributeFormatter('id', 'id', submodel='Port'),
                 AttributeFormatter('name', 'portName', submodel='Port'),
                 AttributeFormatter('device_owner', 'vlanType',
                                    lambda *args: 'allowed',
                                    submodel='Port'),
                 AttributeFormatter('network_id', 'networkId',
                                    submodel='Port'),
                 AttributeFormatter('device_id', 'instanceId',
                                    submodel='Port'),
                 AttributeFormatter('device_owner', 'instanceType',
                                    lambda *args: 'router',
                                    submodel='Port'),
                 AttributeFormatter('project_id', 'tenantId',
                                    modify_blank_project_id, submodel='Port')]
    get_db_resources = staticmethod(db_lib.get_router_ports)


class VmPorts(AristaResourcesBase):

    endpoint = 'region/%(region)s/port?type=vm'
    formatter = [AttributeFormatter('id', 'id', submodel='Port'),
                 AttributeFormatter('name', 'portName', submodel='Port'),
                 AttributeFormatter('device_owner', 'vlanType',
                                    lambda *args: 'allowed', submodel='Port'),
                 AttributeFormatter('network_id', 'networkId',
                                    submodel='Port'),
                 AttributeFormatter('device_id', 'instanceId',
                                    submodel='Port'),
                 AttributeFormatter('device_owner', 'instanceType',
                                    lambda *args: 'vm', submodel='Port'),
                 AttributeFormatter('project_id', 'tenantId',
                                    submodel='Port')]
    get_db_resources = staticmethod(db_lib.get_vm_ports)

    @classmethod
    def format_for_create(cls, resource):
        # This is needed until we can update the upstream trunk port
        # handling to add device_id to subports
        port = getattr(resource, 'Port')
        if port['device_owner'] == t_const.TRUNK_SUBPORT_OWNER:
            parent_port = db_lib.get_parent(port['id'])
            port['device_id'] = parent_port.get('device_id')
        return super(VmPorts, cls).format_for_create(resource)


class BaremetalPorts(AristaResourcesBase):

    def _get_vlan_type(device_owner, resource):
        if (device_owner.startswith(n_const.DEVICE_OWNER_BAREMETAL_PREFIX) or
                device_owner.startswith(n_const.DEVICE_OWNER_COMPUTE_PREFIX)):
            return 'native'
        else:
            return 'allowed'

    endpoint = 'region/%(region)s/port?type=baremetal'
    formatter = [AttributeFormatter('id', 'id', submodel='Port'),
                 AttributeFormatter('name', 'portName', submodel='Port'),
                 AttributeFormatter('device_owner', 'vlanType',
                                    _get_vlan_type, submodel='Port'),
                 AttributeFormatter('network_id', 'networkId',
                                    submodel='Port'),
                 AttributeFormatter('device_id', 'instanceId',
                                    submodel='Port'),
                 AttributeFormatter('device_owner', 'instanceType',
                                    lambda *args: 'baremetal',
                                    submodel='Port'),
                 AttributeFormatter('project_id', 'tenantId', submodel='Port')]
    get_db_resources = staticmethod(db_lib.get_baremetal_ports)


class PortBindings(AristaResourcesBase):

    endpoint = 'region/%(region)s/portbinding'
    get_db_resources = staticmethod(db_lib.get_port_bindings)

    @staticmethod
    def maybe_strip_fqdn(id):
        port_id, binding = id
        if type(binding) != tuple:
            binding = utils.hostname(binding)
        return (port_id, binding)

    def force_resource_update(self, id):
        id = self.maybe_strip_fqdn(id)
        super(PortBindings, self).force_resource_update(id)

    def _delete_neutron_resource(self, id):
        id = self.maybe_strip_fqdn(id)
        super(PortBindings, self)._delete_neutron_resource(id)

    @classmethod
    def get_resource_ids(cls, resource):
        resource_ids = set()
        port_id = resource['portId']
        for host_binding in resource.get('hostBinding', []):
            resource_ids.add((port_id, host_binding['host']))
        for switch_binding in resource.get('switchBinding', []):
            resource_ids.add((port_id, (switch_binding['switch'],
                                        switch_binding['interface'])))
        return resource_ids

    @classmethod
    def format_for_delete(cls, id):
        model = dict()
        port_id, binding = id
        model['portId'] = port_id
        if type(binding) == tuple:
            switch, interface = binding
            model['switchBinding'] = [{'switch': switch,
                                       'interface': interface}]
        else:
            host = binding
            model['hostBinding'] = [{'host': host}]
        return model

    @classmethod
    def format_for_create(cls, binding):
        cvx_resources = {}
        # First build the list of segments to which the port is bound
        # binding levels are in order from       0 -> highest
        #               which is typically   vxlan -> vlan
        # The Arista JSON API depends on this ordering being present in
        # the segments list
        segments = []
        for binding_level in (sorted(binding['levels'],
                                     key=lambda bl: bl.level)):
            segments.append({'id': binding_level.segment_id})

        # Determine if this is a switch or host bindings and populate
        # the appropriate model attribute accordingly
        host = utils.hostname(binding['host'])
        port_id = binding['port_id']
        # If the binding profile isn't valid json, this is a host binding
        try:
            profile = json.loads(binding.profile)
        except ValueError:
            profile = {}
        if profile.get('local_link_information'):
            for link in profile['local_link_information']:
                switch_binding = {'host': host,
                                  'switch': link['switch_id'],
                                  'interface': link['port_id'],
                                  'segment': segments}
                binding_key = (link['switch_id'], link['port_id'])
                cvx_resources[(port_id, binding_key)] = {
                    'portId': port_id,
                    'hostBinding': [],
                    'switchBinding': [switch_binding]}
        else:
            cvx_resources[(port_id, host)] = {
                'portId': port_id,
                'hostBinding': [{'host': host, 'segment': segments}],
                'switchBinding': []}
        return cvx_resources
