# Copyright (c) 2016 Dell Inc. or its subsidiaries.
# 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.

from oslo_log import log
from oslo_utils import excutils
from oslo_utils import importutils

storops = importutils.try_import('storops')
if storops:
    from storops import exception as storops_ex
else:
    # Set storops_ex to be None for unit test
    storops_ex = None

from cinder import coordination
from cinder import exception
from cinder.i18n import _
from cinder.volume.drivers.dell_emc.unity import utils

LOG = log.getLogger(__name__)


class UnityClient(object):
    def __init__(self, host, username, password, verify_cert=True):
        if storops is None:
            msg = _('Python package storops is not installed which '
                    'is required to run Unity driver.')
            raise exception.VolumeBackendAPIException(data=msg)
        self._system = None
        self.host = host
        self.username = username
        self.password = password
        self.verify_cert = verify_cert
        self.host_cache = {}

    @property
    def system(self):
        if self._system is None:
            self._system = storops.UnitySystem(
                host=self.host, username=self.username, password=self.password,
                verify=self.verify_cert)
        return self._system

    def get_serial(self):
        return self.system.serial_number

    def create_lun(self, name, size, pool, description=None,
                   io_limit_policy=None, is_thin=None,
                   is_compressed=None, cg_name=None):
        """Creates LUN on the Unity system.

        :param name: lun name
        :param size: lun size in GiB
        :param pool: UnityPool object represent to pool to place the lun
        :param description: lun description
        :param io_limit_policy: io limit on the LUN
        :param is_thin: if False, a thick LUN will be created
        :param is_compressed: is compressed LUN enabled
        :param cg_name: the name of cg to join if any
        :return: UnityLun object
        """
        try:
            lun = pool.create_lun(lun_name=name, size_gb=size,
                                  description=description,
                                  io_limit_policy=io_limit_policy,
                                  is_thin=is_thin,
                                  is_compression=is_compressed)
        except storops_ex.UnityLunNameInUseError:
            LOG.debug("LUN %s already exists. Return the existing one.",
                      name)
            lun = self.system.get_lun(name=name)
        return lun

    def thin_clone(self, lun_or_snap, name, io_limit_policy=None,
                   description=None, new_size_gb=None):
        try:
            lun = lun_or_snap.thin_clone(
                name=name, io_limit_policy=io_limit_policy,
                description=description)
        except storops_ex.UnityLunNameInUseError:
            LOG.debug("LUN(thin clone) %s already exists. "
                      "Return the existing one.", name)
            lun = self.system.get_lun(name=name)
        if new_size_gb is not None and new_size_gb > lun.total_size_gb:
            lun = self.extend_lun(lun.get_id(), new_size_gb)
        return lun

    def delete_lun(self, lun_id):
        """Deletes LUN on the Unity system.

        :param lun_id: id of the LUN
        """
        try:
            lun = self.system.get_lun(_id=lun_id)
        except storops_ex.UnityResourceNotFoundError:
            LOG.debug("Cannot get LUN %s from unity. Do nothing.", lun_id)
            return

        def _delete_lun_if_exist(force_snap_delete=False):
            """Deletes LUN, skip if it doesn't exist."""
            try:
                lun.delete(force_snap_delete=force_snap_delete)
            except storops_ex.UnityResourceNotFoundError:
                LOG.debug("LUN %s doesn't exist. Deletion is not needed.",
                          lun_id)

        try:
            _delete_lun_if_exist()
        except storops_ex.UnityDeleteLunInReplicationError:
            LOG.info("LUN %s is participating in replication sessions. "
                     "Delete replication sessions first",
                     lun_id)
            self.delete_lun_replications(lun_id)

            # It could fail if not pass in force_snap_delete when
            # deleting the lun immediately after
            # deleting the replication sessions.
            _delete_lun_if_exist(force_snap_delete=True)

    def delete_lun_replications(self, lun_id):
        LOG.debug("Deleting all the replication sessions which are from "
                  "lun %s", lun_id)
        try:
            rep_sessions = self.system.get_replication_session(
                src_resource_id=lun_id)
        except storops_ex.UnityResourceNotFoundError:
            LOG.debug("No replication session found from lun %s. Do nothing.",
                      lun_id)
        else:
            for session in rep_sessions:
                try:
                    session.delete()
                except storops_ex.UnityResourceNotFoundError:
                    LOG.debug("Replication session %s doesn't exist. "
                              "Skip the deletion.", session.get_id())

    def get_lun(self, lun_id=None, name=None):
        """Gets LUN on the Unity system.

        :param lun_id: id of the LUN
        :param name: name of the LUN
        :return: `UnityLun` object
        """
        lun = None
        if lun_id is None and name is None:
            LOG.warning(
                "Both lun_id and name are None to get LUN. Return None.")
        else:
            try:
                lun = self.system.get_lun(_id=lun_id, name=name)
            except storops_ex.UnityResourceNotFoundError:
                LOG.warning(
                    "LUN id=%(id)s, name=%(name)s doesn't exist.",
                    {'id': lun_id, 'name': name})
        return lun

    def extend_lun(self, lun_id, size_gib):
        lun = self.system.get_lun(lun_id)
        try:
            lun.total_size_gb = size_gib
        except storops_ex.UnityNothingToModifyError:
            LOG.debug("LUN %s is already expanded. LUN expand is not needed.",
                      lun_id)
        return lun

    def migrate_lun(self, lun_id, dest_pool_id):
        lun = self.system.get_lun(lun_id)
        dest_pool = self.system.get_pool(dest_pool_id)
        return lun.migrate(dest_pool)

    def get_pools(self):
        """Gets all storage pools on the Unity system.

        :return: list of UnityPool object
        """
        return self.system.get_pool()

    def create_snap(self, src_lun_id, name=None):
        """Creates a snapshot of LUN on the Unity system.

        :param src_lun_id: the source LUN ID of the snapshot.
        :param name: the name of the snapshot. The Unity system will give one
                     if `name` is None.
        """
        try:
            lun = self.get_lun(lun_id=src_lun_id)
            snap = lun.create_snap(name, is_auto_delete=False)
        except storops_ex.UnitySnapNameInUseError as err:
            LOG.debug(
                "Snap %(snap_name)s already exists on LUN %(lun_id)s. "
                "Return the existing one. Message: %(err)s",
                {'snap_name': name,
                 'lun_id': src_lun_id,
                 'err': err})
            snap = self.get_snap(name=name)
        return snap

    @staticmethod
    def delete_snap(snap):
        if snap is None:
            LOG.debug("Snap to delete is None, skipping deletion.")
            return

        try:
            snap.delete()
        except storops_ex.UnityResourceNotFoundError as err:
            LOG.debug("Snap %(snap_name)s may be deleted already. "
                      "Message: %(err)s",
                      {'snap_name': snap.name,
                       'err': err})
        except storops_ex.UnityDeleteAttachedSnapError as err:
            with excutils.save_and_reraise_exception():
                LOG.warning("Failed to delete snapshot %(snap_name)s "
                            "which is in use. Message: %(err)s",
                            {'snap_name': snap.name, 'err': err})

    def get_snap(self, name=None):
        try:
            return self.system.get_snap(name=name)
        except storops_ex.UnityResourceNotFoundError as err:
            LOG.warning("Snapshot %(name)s doesn't exist. Message: %(err)s",
                        {'name': name, 'err': err})
        return None

    @coordination.synchronized('{self.host}-{name}')
    def create_host(self, name):
        return self.create_host_wo_lock(name)

    def create_host_wo_lock(self, name):
        """Provides existing host if exists else create one."""
        if name not in self.host_cache:
            try:
                host = self.system.get_host(name=name)
            except storops_ex.UnityResourceNotFoundError:
                LOG.debug('Host %s not found.  Create a new one.',
                          name)
                host = self.system.create_host(name=name)

            self.host_cache[name] = host
        else:
            host = self.host_cache[name]
        return host

    def delete_host_wo_lock(self, host):
        host.delete()
        del self.host_cache[host.name]

    def update_host_initiators(self, host, uids):
        """Updates host with the supplied uids."""
        host_initiators_ids = self.get_host_initiator_ids(host)
        un_registered = [h for h in uids if h not in host_initiators_ids]
        if un_registered:
            for uid in un_registered:
                try:
                    host.add_initiator(uid, force_create=True)
                except storops_ex.UnityHostInitiatorExistedError:
                    # This make concurrent modification of
                    # host initiators safe
                    LOG.debug(
                        'The uid(%s) was already in '
                        '%s.', uid, host.name)
            host.update()
            # Update host cached with new initiators.
            self.host_cache[host.name] = host

        return host

    @staticmethod
    def get_host_initiator_ids(host):
        fc = host.fc_host_initiators
        fc_ids = [] if fc is None else fc.initiator_id
        iscsi = host.iscsi_host_initiators
        iscsi_ids = [] if iscsi is None else iscsi.initiator_id
        return fc_ids + iscsi_ids

    @staticmethod
    def attach(host, lun_or_snap):
        """Attaches a `UnityLun` or `UnitySnap` to a `UnityHost`.

        :param host: `UnityHost` object
        :param lun_or_snap: `UnityLun` or `UnitySnap` object
        :return: hlu
        """
        try:
            return host.attach(lun_or_snap, skip_hlu_0=True)
        except storops_ex.UnityResourceAlreadyAttachedError:
            return host.get_hlu(lun_or_snap)

    @staticmethod
    def detach(host, lun_or_snap):
        """Detaches a `UnityLun` or `UnitySnap` from a `UnityHost`.

        :param host: `UnityHost` object
        :param lun_or_snap: `UnityLun` object
        """
        lun_or_snap.update()
        host.detach(lun_or_snap)

    @staticmethod
    def detach_all(lun):
        """Detaches a `UnityLun` from all hosts.

        :param lun: `UnityLun` object
        """
        lun.update()
        lun.detach_from(host=None)

    def get_ethernet_ports(self):
        return self.system.get_ethernet_port()

    def get_iscsi_target_info(self, allowed_ports=None):
        portals = self.system.get_iscsi_portal()
        portals = portals.shadow_copy(port_ids=allowed_ports)
        return [{'portal': utils.convert_ip_to_portal(p.ip_address),
                 'iqn': p.iscsi_node.name}
                for p in portals]

    def get_fc_ports(self):
        return self.system.get_fc_port()

    def get_fc_target_info(self, host=None, logged_in_only=False,
                           allowed_ports=None):
        """Get the ports WWN of FC on array.

        :param host: the host to which the FC port is registered.
        :param logged_in_only: whether to retrieve only the logged-in port.

        :return: the WWN of FC ports. For example, the FC WWN on array is like:
         50:06:01:60:89:20:09:25:50:06:01:6C:09:20:09:25.
         This function removes the colons and returns the last 16 bits:
         5006016C09200925.
        """
        wwns = set()
        if logged_in_only:
            for paths in filter(None, host.fc_host_initiators.paths):
                paths = paths.shadow_copy(is_logged_in=True)
                # `paths.fc_port` is just a list, not a UnityFcPortList,
                # so use filter instead of shadow_copy here.
                wwns.update(p.wwn.upper()
                            for p in filter(
                    lambda fcp: (allowed_ports is None or
                                 fcp.get_id() in allowed_ports),
                    paths.fc_port))
        else:
            ports = self.get_fc_ports()
            ports = ports.shadow_copy(port_ids=allowed_ports)
            wwns.update(p.wwn.upper() for p in ports)
        return [wwn.replace(':', '')[16:] for wwn in wwns]

    def create_io_limit_policy(self, name, max_iops=None, max_kbps=None):
        try:
            limit = self.system.create_io_limit_policy(
                name, max_iops=max_iops, max_kbps=max_kbps)
        except storops_ex.UnityPolicyNameInUseError:
            limit = self.system.get_io_limit_policy(name=name)
        return limit

    def get_io_limit_policy(self, qos_specs):
        limit_policy = None
        if qos_specs is not None:
            limit_policy = self.create_io_limit_policy(
                qos_specs['id'],
                qos_specs.get(utils.QOS_MAX_IOPS),
                qos_specs.get(utils.QOS_MAX_BWS))
        return limit_policy

    def get_pool_id_by_name(self, name):
        pool = self.system.get_pool(name=name)
        return pool.get_id()

    def get_pool_name(self, lun_name):
        lun = self.system.get_lun(name=lun_name)
        return lun.pool_name

    def restore_snapshot(self, snap_name):
        snap = self.get_snap(snap_name)
        return snap.restore(delete_backup=True)

    def create_cg(self, name, description=None, lun_add=None):
        try:
            cg = self.system.create_cg(name, description=description,
                                       lun_add=lun_add)
        except storops_ex.UnityConsistencyGroupNameInUseError:
            LOG.debug('CG %s already exists. Return the existing one.', name)
            cg = self.system.get_cg(name=name)
        return cg

    def get_cg(self, name):
        try:
            cg = self.system.get_cg(name=name)
        except storops_ex.UnityResourceNotFoundError:
            LOG.info('CG %s not found.', name)
            return None
        else:
            return cg

    def delete_cg(self, name):
        cg = self.get_cg(name)
        if cg:
            cg.delete()  # Deleting cg will also delete the luns in it

    def update_cg(self, name, add_lun_ids, remove_lun_ids):
        cg = self.get_cg(name)
        cg.update_lun(add_luns=[self.get_lun(lun_id=lun_id)
                                for lun_id in add_lun_ids],
                      remove_luns=[self.get_lun(lun_id=lun_id)
                                   for lun_id in remove_lun_ids])

    def create_cg_snap(self, cg_name, snap_name=None):
        cg = self.get_cg(cg_name)
        # Creating snap of cg will create corresponding snaps of luns in it
        return cg.create_snap(name=snap_name, is_auto_delete=False)

    def filter_snaps_in_cg_snap(self, cg_snap_id):
        return self.system.get_snap(snap_group=cg_snap_id).list

    @staticmethod
    def create_replication(src_lun, max_time_out_of_sync,
                           dst_pool_id, remote_system):
        """Creates a new lun on remote system and sets up replication to it."""
        return src_lun.replicate_with_dst_resource_provisioning(
            max_time_out_of_sync, dst_pool_id, remote_system=remote_system,
            dst_lun_name=src_lun.name)

    def get_remote_system(self, name=None):
        """Gets remote system on the Unity system.

        :param name: remote system name.
        :return: remote system.
        """
        try:
            return self.system.get_remote_system(name=name)
        except storops_ex.UnityResourceNotFoundError:
            LOG.warning("Not found remote system with name %s. Return None.",
                        name)
            return None

    def get_replication_session(self, name=None,
                                src_resource_id=None, dst_resource_id=None):
        """Gets replication session via its name.

        :param name: replication session name.
        :param src_resource_id: replication session's src_resource_id.
        :param dst_resource_id: replication session's dst_resource_id.
        :return: replication session.
        """
        try:
            return self.system.get_replication_session(
                name=name, src_resource_id=src_resource_id,
                dst_resource_id=dst_resource_id)
        except storops_ex.UnityResourceNotFoundError:
            raise ClientReplicationError(
                'Replication session with name %(name)s not found.'.format(
                    name=name))

    def failover_replication(self, rep_session):
        """Fails over a replication session.

        :param rep_session: replication session to fail over.
        """
        name = rep_session.name
        LOG.debug('Failing over replication: %s', name)
        try:
            # In OpenStack, only support to failover triggered from secondary
            # backend because the primary could be down. Then `sync=False`
            # is required here which means it won't sync from primary to
            # secondary before failover.
            return rep_session.failover(sync=False)
        except storops_ex.UnityException as ex:
            raise ClientReplicationError(
                'Failover of replication: %(name)s failed, '
                'error: %(err)s'.format(name=name, err=ex)
            )
        LOG.debug('Replication: %s failed over', name)

    def failback_replication(self, rep_session):
        """Fails back a replication session.

        :param rep_session: replication session to fail back.
        """
        name = rep_session.name
        LOG.debug('Failing back replication: %s', name)
        try:
            # If the replication was failed-over before initial copy done,
            # following failback will fail without `force_full_copy` because
            # the primary # and secondary data have no common base.
            # `force_full_copy=True` has no effect if initial copy done.
            return rep_session.failback(force_full_copy=True)
        except storops_ex.UnityException as ex:
            raise ClientReplicationError(
                'Failback of replication: %(name)s failed, '
                'error: %(err)s'.format(name=name, err=ex)
            )
        LOG.debug('Replication: %s failed back', name)


class ClientReplicationError(exception.CinderException):
    pass
