# Copyright 2016 Mirantis, Inc.
#
#    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 abc

from neutron_lib.utils.helpers import parse_mappings
from oslo_concurrency import lockutils
from oslo_log import log as logging
from oslo_utils import strutils
import stevedore

from networking_generic_switch import config as gsw_conf
from networking_generic_switch import exceptions as gsw_exc

GENERIC_SWITCH_NAMESPACE = 'generic_switch.devices'
LOG = logging.getLogger(__name__)

# Internal ngs options will not be passed to driver.
NGS_INTERNAL_OPTS = [
    {'name': 'ngs_mac_address'},
    # Comma-separated list of names of interfaces to be added to each network.
    {'name': 'ngs_trunk_ports'},
    {'name': 'ngs_port_default_vlan'},
    # Comma-separated list of physical networks to which this switch is mapped.
    {'name': 'ngs_physical_networks'},
    # Comma-separated list of entries formatted as "<type>:<algorithm>",
    # specifying SSH algorithms to disable.
    {'name': 'ngs_ssh_disabled_algorithms'},
    {'name': 'ngs_ssh_connect_timeout', 'default': 60},
    {'name': 'ngs_ssh_connect_interval', 'default': 10},
    {'name': 'ngs_ssh_reuse_connection', 'default': False},
    {'name': 'ngs_max_connections', 'default': 1},
    {'name': 'ngs_switchport_mode', 'default': 'access'},
    # If True, disable switch ports that are not in use.
    {'name': 'ngs_disable_inactive_ports', 'default': False},
    # String format for network name to configure on switches.
    # Accepts {network_id} and {segmentation_id} formatting options.
    {'name': 'ngs_network_name_format', 'default': '{network_id}'},
    # If false, ngs will not add and delete VLANs from switches
    {'name': 'ngs_manage_vlans', 'default': True},
    # If False, ngs will skip saving configuration on devices
    {'name': 'ngs_save_configuration', 'default': True},
    # When true try to batch up in flight switch requests
    {'name': 'ngs_batch_requests', 'default': False},
    # The following three are used in the Fake device driver.
    {'name': 'ngs_fake_sleep_min_s'},
    {'name': 'ngs_fake_sleep_max_s'},
    {'name': 'ngs_fake_failure_prob'},
    # Allow list for VLANs and ports for this switch
    # default open, but setting empty string blocks all ports
    {'name': 'ngs_allowed_vlans'},
    {'name': 'ngs_allowed_ports'},
    # Require security groups to be enabled on a per-device basis
    {'name': 'ngs_security_groups_enabled', 'default': False},
    # Force NETCONF datastore target ('candidate' or 'running').
    # Unset (default) auto-detects from server capabilities.
    {'name': 'ngs_netconf_target'},
    # XML config payload sent via edit-config to the running datastore
    # to persist configuration.
    {'name': 'ngs_netconf_save_config'},
    # OpenConfig: L2/L3 forwarding instance for VLANs
    {'name': 'ngs_openconfig_network_instance', 'default': 'default'},
    # JSON dict {"pattern": "...", "repl": "..."} for regex substitution
    # on port IDs from LLDP
    {'name': 'ngs_port_id_re_sub'},
    # OpenConfig: comma-separated list of disabled properties
    # (e.g. "port_mtu")
    {'name': 'ngs_openconfig_disabled_properties'},
    # Whether to use confirmed commit when the switch advertises it.
    {'name': 'ngs_netconf_confirmed_commit', 'default': True},
    # Rollback timeout (seconds) for the tentative confirmed commit.
    {'name': 'ngs_netconf_confirmed_commit_timeout', 'default': 5},
]

EM_SEMAPHORE = 'ngs_device_manager'
DEVICES = {}


@lockutils.synchronized(EM_SEMAPHORE)
def get_devices():
    global DEVICES
    gsw_devices = gsw_conf.get_devices()
    for device_name, device_cfg in gsw_devices.items():
        if device_name in DEVICES:
            continue
        DEVICES[device_name] = device_manager(device_cfg, device_name)
    return DEVICES


def device_manager(device_cfg, device_name=""):
    device_type = device_cfg.get('device_type', '')
    try:
        mgr = stevedore.driver.DriverManager(
            namespace=GENERIC_SWITCH_NAMESPACE,
            name=device_type,
            invoke_on_load=True,
            invoke_args=(device_cfg, device_name),
            on_load_failure_callback=_load_failure_hook
        )
    except stevedore.exception.NoUniqueMatch as exc:
        raise gsw_exc.GenericSwitchEntrypointLoadError(
            ep='.'.join((GENERIC_SWITCH_NAMESPACE, device_type)),
            err=exc)
    return mgr.driver


def _load_failure_hook(manager, entrypoint, exception):
    LOG.error("Driver manager %(manager)s failed to load device plugin "
              "%(entrypoint)s: %(exp)s",
              {'manager': manager, 'entrypoint': entrypoint, 'exp': exception})
    raise gsw_exc.GenericSwitchEntrypointLoadError(
        ep=entrypoint,
        err=exception)


class GenericSwitchDevice(abc.ABC):

    def __init__(self, device_cfg, device_name=""):
        self.ngs_config = {}
        self.config = {}
        self.device_name = device_name
        # Do not expose NGS internal options to device config.
        for opt in NGS_INTERNAL_OPTS:
            opt_name = opt['name']
            if opt_name in device_cfg.keys():
                self.ngs_config[opt_name] = device_cfg.pop(opt_name)
            elif 'default' in opt:
                self.ngs_config[opt_name] = opt['default']
        # Ignore any other option starting with 'ngs_' (to avoid passing
        # these options to Netmiko)
        for opt_name in [o for o in device_cfg.keys() if o.startswith("ngs_")]:
            LOG.warning("Ignoring unknown option '%(opt_name)s' for "
                        "device %(device)s",
                        {'opt_name': opt_name, 'device': self.device_name})
            device_cfg.pop(opt_name)

        self.config = device_cfg

        self._validate_network_name_format()

    @property
    def support_trunk_on_ports(self):
        return False

    @property
    def support_trunk_on_bond_ports(self):
        return False

    @property
    def trunk_vlans_converge(self):
        """Whether trunk VLAN list should be fully converged on each operation.

        When True, the mechanism driver queries the Neutron DB for all VLAN
        segments on the physical network and passes the complete set
        via the physnet_vlans parameter.
        """
        return False

    def _validate_network_name_format(self):
        """Validate the network name format configuration option."""
        network_name_format = self.ngs_config['ngs_network_name_format']
        # The format can include '{network_id}' and '{segmentation_id}'.
        try:
            network_name_format.format(network_id='dummy',
                                       segmentation_id='dummy')
        except (IndexError, KeyError):
            raise gsw_exc.GenericSwitchNetworkNameFormatInvalid(
                name_format=network_name_format)

    def get_trunk_ports(self):
        """Return a list of trunk ports on this switch."""
        trunk_ports = self.ngs_config.get('ngs_trunk_ports')
        if not trunk_ports:
            return []
        return [port.strip() for port in trunk_ports.split(',')]

    def _get_port_default_vlan(self):
        """Return a default vlan of switch's interface if you specify."""
        return self.ngs_config.get('ngs_port_default_vlan', None)

    def get_physical_networks(self):
        """Return a list of physical networks mapped to this switch."""
        physnets = self.ngs_config.get('ngs_physical_networks')
        if not physnets:
            return []
        return [net.strip() for net in physnets.split(',')]

    def _disable_inactive_ports(self):
        """Return whether inactive ports should be disabled."""
        return strutils.bool_from_string(
            self.ngs_config['ngs_disable_inactive_ports'])

    def _get_save_configuration(self):
        """Return whether configuration should be saved on device."""
        return strutils.bool_from_string(
            self.ngs_config['ngs_save_configuration'])

    def _get_network_name(self, network_id, segmentation_id):
        """Return a network name to configure on switches.

        :param network_id: ID of the network.
        :param segmentation_id: segmentation ID of the network.
        :returns: a formatted network name.
        """
        network_name_format = self.ngs_config['ngs_network_name_format']
        return network_name_format.format(network_id=network_id,
                                          segmentation_id=segmentation_id)

    def _get_ssh_disabled_algorithms(self):
        """Return a dict of SSH algorithms to disable.

        The dict is in a suitable format for feeding to Netmiko/Paramiko.
        """
        algorithms = self.ngs_config.get('ngs_ssh_disabled_algorithms')
        if not algorithms:
            return {}
        # Builds a dict: keys are types, values are list of algorithms
        return parse_mappings(algorithms.split(','), unique_keys=False,
                              unique_values=False)

    def _do_vlan_management(self):
        """Check if drivers should add and remove VLANs from switches."""
        return strutils.bool_from_string(self.ngs_config['ngs_manage_vlans'])

    def _batch_requests(self):
        """Return whether to batch up requests to the switch."""
        return strutils.bool_from_string(
            self.ngs_config['ngs_batch_requests'])

    def _reuse_connection(self):
        """Return whether to reuse ssh connections."""
        return strutils.bool_from_string(
            self.ngs_config['ngs_ssh_reuse_connection'])

    def _get_allowed_vlans(self):
        allowed_vlans = self.ngs_config.get('ngs_allowed_vlans')
        if allowed_vlans is None:
            return None
        return allowed_vlans.split(',')

    def _get_allowed_ports(self):
        allowed_ports = self.ngs_config.get('ngs_allowed_ports')
        if allowed_ports is None:
            return None
        return allowed_ports.split(',')

    def is_allowed(self, port_id, segmentation_id):
        is_port_id_allowed = True
        allowed_ports = self._get_allowed_ports()
        if allowed_ports is not None:
            is_port_id_allowed = port_id in allowed_ports
            LOG.debug("Port %(port_id)s allowed: %(is_port_id_allowed)s",
                      {"port_id": port_id,
                       "is_port_id_allowed": is_port_id_allowed})

        is_vlan_allowed = True
        allowed_vlans = self._get_allowed_vlans()
        if allowed_vlans is not None:
            is_vlan_allowed = str(segmentation_id) in allowed_vlans
            LOG.debug("VLAN %(vlan)s allowed: %(is_allowed)s",
                      {"vlan": segmentation_id,
                       "is_allowed": is_vlan_allowed})

        return is_port_id_allowed and is_vlan_allowed

    @abc.abstractmethod
    def add_network(self, segmentation_id, network_id, physnet_vlans=None):
        """Create a network (VLAN) on the switch.

        When physnet_vlans is provided, drivers can converge the full set of
        trunk VLANs in a single operation rather than making incremental
        changes.

        :param segmentation_id: VLAN ID of the network.
        :param network_id: UUID of the Neutron network.
        :param physnet_vlans: Optional set of all VLAN segmentation IDs that
            should exist on the switch's physical networks.
        """
        pass

    @abc.abstractmethod
    def del_network(self, segmentation_id, network_id, physnet_vlans=None):
        """Delete a network (VLAN) from the switch.

        When physnet_vlans is provided, drivers can converge the full set of
        trunk VLANs in a single operation rather than making incremental
        changes.

        :param segmentation_id: VLAN ID of the network.
        :param network_id: UUID of the Neutron network.
        :param physnet_vlans: Optional set of all VLAN segmentation IDs that
            should remain on the switch's physical networks.
        """
        pass

    @abc.abstractmethod
    def plug_switch_to_network(self, vni: int, segmentation_id: int,
                               physnet: str = None):
        """Configure L2VNI mapping on the switch.

        In VXLAN L2VNI scenarios with hierarchical port binding, Neutron
        creates a VXLAN network (top segment) and dynamically allocates a
        local VLAN (bottom segment) on each switch. This method maps the
        VLAN to the VNI on the switch fabric.

        Called during port binding when both conditions are met:
        - Top bound segment is VXLAN
        - Bottom bound segment is VLAN

        For switches that don't support VXLAN, this can be left as None
        (will log a warning but not fail).

        :param vni: The VXLAN Network Identifier
        :param segmentation_id: VLAN ID to map to the VNI
        :param physnet: Physical network name for per-physnet configuration
                        (optional, for future use).
        :raises: GenericSwitchConfigException on configuration failure
        """
        pass

    @abc.abstractmethod
    def unplug_switch_from_network(self, vni: int, segmentation_id: int,
                                   physnet: str = None):
        """Remove L2VNI mapping from the switch.

        Removes the VNI-to-VLAN mapping when the last port on a VLAN is
        unplugged. Called automatically by the cleanup logic in
        _unplug_port_from_segment() after verifying no ports remain via
        vlan_has_ports().

        Should be idempotent - safely handle cases where the VNI is
        already removed.

        Example (Cisco NX-OS):
            interface nve1
              no member vni 5000
            vlan 100
              no vn-segment

        :param vni: The VXLAN Network Identifier to remove
        :param segmentation_id: VLAN ID from which to remove the VNI mapping
        :param physnet: Physical network name (optional, for signature
                        consistency)
        :raises: GenericSwitchConfigException on configuration failure
        """
        pass

    @abc.abstractmethod
    def vlan_has_ports(self, segmentation_id: int) -> bool:
        """Check if a VLAN has any switch ports currently assigned.

        Used by L2VNI cleanup logic to determine if it's safe to remove
        the VNI mapping. The VNI should only be removed when no ports
        remain on the VLAN.

        This is a read-only operation and should not acquire locks.

        Implementations should:
        - Query the switch directly (not rely on cached state)
        - Return True if the VLAN has any ports (access or trunk)
        - Return True on error (conservative - prevents accidental removal)
        - Return True if query command is not implemented

        :param segmentation_id: VLAN ID to check
        :returns: True if VLAN has ports assigned, False if empty
        """
        pass

    @abc.abstractmethod
    def vlan_has_vni(self, segmentation_id: int, vni: int) -> bool:
        """Check if a VLAN already has a specific VNI mapping configured.

        Used for idempotency during port binding to avoid reconfiguring
        the same VNI mapping multiple times when multiple ports bind to
        the same VXLAN network.

        This is a read-only operation and should not acquire locks.

        Implementations should:
        - Query the switch directly (not rely on cached state)
        - Return True only if this exact VNI is configured on this VLAN
        - Return False on error (will attempt to configure)
        - Return False if query command is not implemented

        :param segmentation_id: VLAN ID to check
        :param vni: VNI to check for
        :returns: True if VLAN has this VNI configured, False otherwise
        """
        pass

    @abc.abstractmethod
    def plug_port_to_network(self, port_id, segmentation_id,
                             trunk_details=None, default_vlan=None):
        """Plug port into network.

        :param port_id: The name of the switch interface
        :param segmentation_id: VLAN identifier of the network used as access
               or native VLAN for port.

        :param trunk_details: trunk information if port is a part of trunk
        :param default_vlan: Default VLAN identifier if port is not configured
        """
        pass

    @abc.abstractmethod
    def delete_port(self, port_id, segmentation_id, trunk_details=None,
                    default_vlan=None):
        """Delete port from specific network.

        :param port_id: The name of the switch interface
        :param segmentation_id: VLAN identifier of the network used as access
               or native VLAN for port.

        :param trunk_details: trunk information if port is a part of trunk
        :param default_vlan: Default VLAN identifier if port is not configured
        """
        pass

    def plug_bond_to_network(self, bond_id, segmentation_id,
                             trunk_details=None, default_vlan=None):
        """Plug bond port into network.

        :param port_id: The name of the switch interface
        :param segmentation_id: VLAN identifier of the network used as access
               or native VLAN for port.

        :param trunk_details: trunk information if port is a part of trunk
        :param default_vlan: Default VLAN identifier if port is not configured
        """
        kwargs = {}
        if trunk_details:
            kwargs["trunk_details"] = trunk_details
        if default_vlan:
            kwargs["default_vlan"] = default_vlan
        # Fall back to interface method.
        return self.plug_port_to_network(bond_id, segmentation_id, **kwargs)

    def unplug_bond_from_network(self, bond_id, segmentation_id,
                                 trunk_details=None, default_vlan=None):
        """Unplug bond port from network.

        :param port_id: The name of the switch interface
        :param segmentation_id: VLAN identifier of the network used as access
               or native VLAN for port.

        :param trunk_details: trunk information if port is a part of trunk
        :param default_vlan: Default VLAN identifier if port is not configured
        """
        kwargs = {}
        if trunk_details:
            kwargs["trunk_details"] = trunk_details
        if default_vlan:
            kwargs["default_vlan"] = default_vlan
        # Fall back to interface method.
        return self.delete_port(bond_id, segmentation_id, **kwargs)

    @abc.abstractmethod
    def add_subports_on_trunk(self, binding_profile, port_id, subports,
                              trunk_details=None):
        """Allow subports on trunk

        :param binding_profile: Binding profile of parent port
        :param port_id: The name of the switch port from
               Local Link Information
        :param subports: List with subports objects.
        :param trunk_details: Full trunk details dict from the parent port,
               including all sub_ports. Used by convergence-capable drivers
               to replace the entire trunk VLAN set atomically.
        """
        pass

    @abc.abstractmethod
    def del_subports_on_trunk(self, binding_profile, port_id, subports,
                              trunk_details=None):
        """Remove subports from trunk

        :param binding_profile: Binding profile of parent port
        :param port_id: The name of the switch port from
               Local Link Information
        :param subports: List with subports objects.
        :param trunk_details: Full trunk details dict from the parent port,
               including remaining sub_ports after deletion. Used by
               convergence-capable drivers to replace the entire trunk
               VLAN set atomically.
        """
        pass

    @abc.abstractmethod
    def add_security_group(self, sg):
        """Add a security group to a switch

        :param sg: Security group object including rules
        """
        pass

    @abc.abstractmethod
    def update_security_group(self, sg):
        """Updates an existing a security group on a switch

        Rules may have been added or deleted so the driver
        needs to update the switch state to accurately reflect
        the provided security group.

        :param sg: Security group object including rules
        """
        pass

    @abc.abstractmethod
    def del_security_group(self, sg_id):
        """Delete a security group

        :param sg_id: Security group ID
        """
        pass

    @abc.abstractmethod
    def bind_security_group(self, sg, port_id, port_ids):
        """Apply a security group to a port

        The rules in the provided security group will also be
        used to assert the state with the switch.

        :param sg: Security group object including rules
        :param port_id: Name of switch port to bind group to
        :param port_ids: Names of all switch ports currently
                         bound to this group
        """
        pass

    @abc.abstractmethod
    def unbind_security_group(self, sg_id, port_id, port_ids):
        """Remove a bound security group from a port

        :param sg_id: ID of security group to unbind
        :param port_id: Name of switch port to unbind group from
        :param port_ids: Names of all switch ports currently
                         bound to this group
        """
        pass
