#    (c) Copyright 2014 Cisco Systems 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.
#


"""
Script to push the zone configuration to Cisco SAN switches.
"""
import random
import re

from eventlet import greenthread
from oslo_concurrency import processutils
from oslo_log import log as logging
from oslo_utils import excutils
import six

from cinder import exception
from cinder.i18n import _
from cinder import ssh_utils
from cinder import utils
from cinder.zonemanager.drivers.cisco import exception as c_exception
import cinder.zonemanager.drivers.cisco.fc_zone_constants as ZoneConstant

LOG = logging.getLogger(__name__)


class CiscoFCZoneClientCLI(object):
    """Cisco FC zone client cli implementation.

    OpenStack Fibre Channel zone client cli connector
    to manage FC zoning in Cisco SAN fabrics.

    Version history:
        1.0 - Initial Cisco FC zone client cli
    """

    switch_ip = None
    switch_port = '22'
    switch_user = 'admin'
    switch_pwd = 'none'

    def __init__(self, ipaddress, username, password, port, vsan):
        """initializing the client."""
        self.switch_ip = ipaddress
        self.switch_port = port
        self.switch_user = username
        self.switch_pwd = password
        self.fabric_vsan = vsan
        self.sshpool = None

    def get_active_zone_set(self):
        """Return the active zone configuration.

        Return active zoneset from fabric. When none of the configurations
        are active then it will return empty map.

        :returns: Map -- active zone set map in the following format

        .. code-block:: python

            {
                'zones':
                    {'openstack50060b0000c26604201900051ee8e329':
                        ['50060b0000c26604', '201900051ee8e329']
                    },
                'active_zone_config': 'OpenStack_Cfg'
            }
        """
        zone_set = {}
        zone = {}
        zone_member = None
        zone_name = None
        switch_data = None
        zone_set_name = None
        try:
            switch_data = self._get_switch_info(
                [ZoneConstant.GET_ACTIVE_ZONE_CFG, self.fabric_vsan,
                 ' | no-more'])
        except c_exception.CiscoZoningCliException:
            with excutils.save_and_reraise_exception():
                LOG.error("Failed getting active zone set "
                          "from fabric %s", self.switch_ip)
        try:
            for line in switch_data:
                # Split on non-word characters,
                line_split = re.split(r'[\s\[\]]+', line)
                if ZoneConstant.CFG_ZONESET in line_split:
                    # zoneset name [name] vsan [vsan]
                    zone_set_name = \
                        line_split[line_split.index(ZoneConstant.CFG_ZONESET)
                                   + 2]
                    continue
                if ZoneConstant.CFG_ZONE in line_split:
                    # zone name [name] vsan [vsan]
                    zone_name = \
                        line_split[line_split.index(ZoneConstant.CFG_ZONE) + 2]
                    zone[zone_name] = list()
                    continue
                if ZoneConstant.CFG_ZONE_MEMBER in line_split:
                    # Examples:
                    # pwwn c0:50:76:05:15:9f:00:12
                    # * fcid 0x1e01c0 [pwwn 50:05:07:68:02:20:48:04] [V7K_N1P2]
                    zone_member = \
                        line_split[
                            line_split.index(ZoneConstant.CFG_ZONE_MEMBER) + 1]
                    zone_member_list = zone.get(zone_name)
                    zone_member_list.append(zone_member)

            zone_set[ZoneConstant.CFG_ZONES] = zone
            zone_set[ZoneConstant.ACTIVE_ZONE_CONFIG] = zone_set_name
        except Exception as ex:
            # In case of parsing error here, it should be malformed cli output.
            msg = _("Malformed zone configuration: (switch=%(switch)s "
                    "zone_config=%(zone_config)s)."
                    ) % {'switch': self.switch_ip,
                         'zone_config': switch_data}
            LOG.error(msg)
            exc_msg = _("Exception: %s") % six.text_type(ex)
            LOG.error(exc_msg)
            raise exception.FCZoneDriverException(reason=msg)

        return zone_set

    def add_zones(self, zones, activate, fabric_vsan, active_zone_set,
                  zone_status):
        """Add zone configuration.

        This method will add the zone configuration passed by user.

        :param zones: Zone names mapped to members and VSANs
                      Zone members are colon separated but case-insensitive

        .. code-block:: python

            {   zonename1:[zonememeber1,zonemember2,...],
                zonename2:[zonemember1, zonemember2,...]...}

            e.g:

            {
                'openstack50060b0000c26604201900051ee8e329':
                  ['50:06:0b:00:00:c2:66:04', '20:19:00:05:1e:e8:e3:29']
            }

        :param activate: True will activate the zone config.
        :param fabric_vsan:
        :param active_zone_set: Active zone set dict retrieved from
                                get_active_zone_set method
        :param zone_status: Status of the zone
        :raises CiscoZoningCliException:
        """
        LOG.debug("Add Zones - Zones passed: %s", zones)

        LOG.debug("Active zone set: %s", active_zone_set)
        zone_list = active_zone_set[ZoneConstant.CFG_ZONES]
        LOG.debug("zone list: %s", zone_list)
        LOG.debug("zone status: %s", zone_status)

        cfg_name = active_zone_set[ZoneConstant.ACTIVE_ZONE_CONFIG]

        zone_cmds = [['conf'],
                     ['zoneset', 'name', cfg_name, 'vsan', fabric_vsan]]

        for zone in zones.keys():
            zone_cmds.append(['zone', 'name', zone])

            for member in zones[zone]:
                zone_cmds.append(['member', 'pwwn', member])

        zone_cmds.append(['end'])

        try:
            LOG.debug("Add zones: Config cmd to run: %s", zone_cmds)
            self._ssh_execute(zone_cmds, True, 1)

            if activate:
                self.activate_zoneset(cfg_name, fabric_vsan, zone_status)
            self._cfg_save()
        except Exception as e:

            msg = _("Creating and activating zone set failed: "
                    "(Zone set=%(zoneset)s error=%(err)s)."
                    ) % {'zoneset': cfg_name, 'err': six.text_type(e)}
            LOG.error(msg)
            raise c_exception.CiscoZoningCliException(reason=msg)

    def update_zones(self, zones, activate, fabric_vsan, operation,
                     active_zone_set, zone_status):
        """Update the zone configuration.

        This method will update the zone configuration passed by user.

        :param zones: zone names mapped to members. Zone members
                      are colon separated but case-insensitive

        .. code-block:: python

            {   zonename1:[zonememeber1, zonemember2,...],
                zonename2:[zonemember1, zonemember2,...]...}

            e.g:

            {
                'openstack50060b0000c26604201900051ee8e329':
                        ['50:06:0b:00:00:c2:66:04',
                         '20:19:00:05:1e:e8:e3:29']
            }

        :param activate: True will activate the zone config.
        :param operation: zone add or zone remove
        :param fabric_vsan: Virtual San #
        :param active_zone_set: Active zone set dict retrieved from
                                get_active_zone_set method
        :param zone_status: Status of the zone
        :raises CiscoZoningCliException:
        """

        LOG.debug("Update Zones - Operation: %(op)s - Zones "
                  "passed: %(zones)s",
                  {'op': operation, 'zones': zones})

        cfg_name = active_zone_set[ZoneConstant.ACTIVE_ZONE_CONFIG]

        zone_cmds = [['conf'],
                     ['zoneset', 'name', cfg_name, 'vsan', fabric_vsan]]
        zone_mod_cmd = []
        if operation == ZoneConstant.ZONE_ADD:
            zone_mod_cmd = ['member', 'pwwn']
        elif operation == ZoneConstant.ZONE_REMOVE:
            zone_mod_cmd = ['no', 'member', 'pwwn']

        for zone, zone_members in zones.items():
            zone_cmds.append(['zone', 'name', zone])
            for member in zone_members:
                zone_cmds.append(zone_mod_cmd + [member])
        zone_cmds.append(['end'])

        try:
            LOG.debug("Update zones: Config cmd to run: %s", zone_cmds)
            self._ssh_execute(zone_cmds, True, 1)

            if activate:
                self.activate_zoneset(cfg_name, fabric_vsan, zone_status)
            self._cfg_save()
        except Exception as e:

            msg = (_("Updating and activating zone set failed: "
                     "(Zone set=%(zoneset)s error=%(err)s).")
                   % {'zoneset': cfg_name, 'err': six.text_type(e)})
            LOG.error(msg)
            raise c_exception.CiscoZoningCliException(reason=msg)

    def activate_zoneset(self, cfgname, fabric_vsan, zone_status):
        """Method to Activate the zone config. Param cfgname - ZonesetName."""

        LOG.debug("zone status: %s", zone_status)

        cmd_list = [['conf'],
                    ['zoneset', 'activate', 'name', cfgname, 'vsan',
                     self.fabric_vsan]]
        if zone_status['mode'] == 'enhanced':
            cmd_list.append(['zone', 'commit', 'vsan', fabric_vsan])

        cmd_list.append(['end'])

        return self._ssh_execute(cmd_list, True, 1)

    def get_zoning_status(self):
        """Return the zoning mode and session for a zoneset."""
        zone_status = {}

        try:
            switch_data = self._get_switch_info(
                [ZoneConstant.GET_ZONE_STATUS, self.fabric_vsan])
        except c_exception.CiscoZoningCliException:
            with excutils.save_and_reraise_exception():
                LOG.error("Failed getting zone status "
                          "from fabric %s", self.switch_ip)
        try:
            for line in switch_data:
                # Split on non-word characters,
                line_split = re.split(r'[\s\[\]]+', line)
                if 'mode:' in line_split:
                    # mode: <enhanced|basic>
                    zone_status['mode'] = line_split[line_split.index('mode:')
                                                     + 1]
                    continue
                if 'session:' in line_split:
                    # session: <none|a value other than none>
                    zone_status['session'] = \
                        line_split[line_split.index('session:') + 1]
                    continue
        except Exception as ex:
            # In case of parsing error here, it should be malformed cli output.
            msg = _("Malformed zone status: (switch=%(switch)s "
                    "zone_config=%(zone_config)s)."
                    ) % {'switch': self.switch_ip,
                         'zone_status': switch_data}
            LOG.error(msg)
            exc_msg = _("Exception: %s") % six.text_type(ex)
            LOG.error(exc_msg)
            raise exception.FCZoneDriverException(reason=msg)

        return zone_status

    def delete_zones(self, zone_names, activate, fabric_vsan, active_zone_set,
                     zone_status):
        """Delete zones from fabric.

        Method to delete the active zone config zones

        params zone_names: zoneNames separated by semicolon
        params activate: True/False
        """

        LOG.debug("zone_names %s", zone_names)
        active_zoneset_name = active_zone_set[ZoneConstant.ACTIVE_ZONE_CONFIG]

        cmds = [['conf'],
                ['zoneset', 'name', active_zoneset_name, 'vsan',
                 fabric_vsan]]

        try:
            for zone in set(zone_names.split(';')):
                cmds.append(['no', 'zone', 'name', zone])

            cmds.append(['end'])

            LOG.debug("Delete zones: Config cmd to run: %s", cmds)
            self._ssh_execute(cmds, True, 1)

            if activate:
                self.activate_zoneset(active_zoneset_name, fabric_vsan,
                                      zone_status)
            self._cfg_save()

        except Exception as e:
            msg = _("Deleting zones failed: (command=%(cmd)s error=%(err)s)."
                    ) % {'cmd': cmds, 'err': six.text_type(e)}
            LOG.error(msg)
            raise c_exception.CiscoZoningCliException(reason=msg)

    def get_nameserver_info(self):
        """Get name server data from fabric.

        This method will return the connected node port wwn list(local
        and remote) for the given switch fabric

        show fcns database
        """
        cli_output = None
        return_list = []
        try:
            cli_output = self._get_switch_info([ZoneConstant.FCNS_SHOW,
                                                self.fabric_vsan])
        except c_exception.CiscoZoningCliException:
            with excutils.save_and_reraise_exception():
                LOG.error("Failed collecting fcns database "
                          "info for fabric %s", self.switch_ip)

        if (cli_output):
            return_list = self._parse_ns_output(cli_output)

        LOG.info("Connector returning fcnsinfo-%s", return_list)

        return return_list

    @utils.retry(processutils.ProcessExecutionError, retries=5)
    def _cfg_save(self):
        cmd = ['copy', 'running-config', 'startup-config']
        self._run_ssh(cmd, True)

    def _get_switch_info(self, cmd_list):
        stdout, stderr, sw_data = None, None, None
        try:
            stdout, stderr = self._run_ssh(cmd_list, True)
            LOG.debug("CLI output from ssh - output: %s", stdout)
            if (stdout):
                sw_data = stdout.splitlines()
            return sw_data
        except processutils.ProcessExecutionError as e:
            msg = _("Error while getting data via ssh: (command=%(cmd)s "
                    "error=%(err)s).") % {'cmd': cmd_list,
                                          'err': six.text_type(e)}
            LOG.error(msg)
            raise c_exception.CiscoZoningCliException(reason=msg)

    def _parse_ns_output(self, switch_data):
        """Parses name server data.

        Parses nameserver raw data and adds the device port wwns to the list

        :returns: List -- list of device port wwn from ns info
        """
        return_list = []
        for line in switch_data:
            if not(" N " in line):
                continue
            linesplit = line.split()
            if len(linesplit) > 2:
                node_port_wwn = linesplit[2]
                return_list.append(node_port_wwn)
            else:
                msg = _("Malformed show fcns database string: %s") % line
                LOG.error(msg)
                raise exception.InvalidParameterValue(err=msg)
        return return_list

    def _run_ssh(self, cmd_list, check_exit_code=True):

        command = ' '.join(cmd_list)

        if not self.sshpool:
            self.sshpool = ssh_utils.SSHPool(self.switch_ip,
                                             self.switch_port,
                                             None,
                                             self.switch_user,
                                             self.switch_pwd,
                                             min_size=1,
                                             max_size=5)
        try:
            with self.sshpool.item() as ssh:
                return processutils.ssh_execute(
                    ssh,
                    command,
                    check_exit_code=check_exit_code)

        except Exception:
            with excutils.save_and_reraise_exception():
                LOG.warning("Error running SSH command: %s", command)

    def _ssh_execute(self, cmd_list, check_exit_code=True, attempts=1):
        """Execute cli with status update.

        Executes CLI commands where status return is expected.

        cmd_list is a list of commands, where each command is itself
        a list of parameters.  We use utils.check_ssh_injection to check each
        command, but then join then with " ; " to form a single command.
        """

        # Check that each command is secure
        for cmd in cmd_list:
            utils.check_ssh_injection(cmd)

        # Combine into a single command.
        command = ' ; '.join(map(lambda x: ' '.join(x), cmd_list))

        if not self.sshpool:
            self.sshpool = ssh_utils.SSHPool(self.switch_ip,
                                             self.switch_port,
                                             None,
                                             self.switch_user,
                                             self.switch_pwd,
                                             min_size=1,
                                             max_size=5)
        stdin, stdout, stderr = None, None, None
        LOG.debug("Executing command via ssh: %s", command)
        last_exception = None
        try:
            with self.sshpool.item() as ssh:
                while attempts > 0:
                    attempts -= 1
                    try:
                        stdin, stdout, stderr = ssh.exec_command(command)
                        channel = stdout.channel
                        exit_status = channel.recv_exit_status()
                        LOG.debug("Exit Status from ssh: %s", exit_status)
                        # exit_status == -1 if no exit code was returned
                        if exit_status != -1:
                            LOG.debug('Result was %s', exit_status)
                            if check_exit_code and exit_status != 0:
                                raise processutils.ProcessExecutionError(
                                    exit_code=exit_status,
                                    stdout=stdout,
                                    stderr=stderr,
                                    cmd=command)
                            else:
                                return True
                        else:
                            return True
                    except Exception as e:
                        LOG.exception('Error executing SSH command.')
                        last_exception = e
                        greenthread.sleep(random.randint(20, 500) / 100.0)
                LOG.debug("Handling error case after SSH: %s", last_exception)
                try:
                    raise processutils.ProcessExecutionError(
                        exit_code=last_exception.exit_code,
                        stdout=last_exception.stdout,
                        stderr=last_exception.stderr,
                        cmd=last_exception.cmd)
                except AttributeError:
                    raise processutils.ProcessExecutionError(
                        exit_code=-1,
                        stdout="",
                        stderr="Error running SSH command",
                        cmd=command)
        except Exception:
            with excutils.save_and_reraise_exception():
                LOG.exception("Error executing command via ssh.")
        finally:
            if stdin:
                stdin.flush()
                stdin.close()
            if stdout:
                stdout.close()
            if stderr:
                stderr.close()

    def cleanup(self):
        self.sshpool = None
