# -*- coding: utf-8 -*-
'''
Utils for the NAPALM modules and proxy.

.. seealso::

    - :mod:`NAPALM grains: select network devices based on their characteristics <salt.grains.napalm>`
    - :mod:`NET module: network basic features <salt.modules.napalm_network>`
    - :mod:`NTP operational and configuration management module <salt.modules.napalm_ntp>`
    - :mod:`BGP operational and configuration management module <salt.modules.napalm_bgp>`
    - :mod:`Routes details <salt.modules.napalm_route>`
    - :mod:`SNMP configuration module <salt.modules.napalm_snmp>`
    - :mod:`Users configuration management <salt.modules.napalm_users>`

.. versionadded:: 2017.7.0
'''

# Import Python libs
from __future__ import absolute_import, unicode_literals, print_function
import copy
import traceback
import logging
import importlib
from functools import wraps

# Import Salt libs
from salt.ext import six
import salt.output
import salt.utils.platform
import salt.utils.args

# Import third party libs
try:
    # will try to import NAPALM
    # https://github.com/napalm-automation/napalm
    # pylint: disable=unused-import,no-name-in-module
    import napalm
    import napalm.base as napalm_base
    # pylint: enable=unused-import,no-name-in-module
    HAS_NAPALM = True
    HAS_NAPALM_BASE = False  # doesn't matter anymore, but needed for the logic below
    try:
        NAPALM_MAJOR = int(napalm.__version__.split('.')[0])
    except AttributeError:
        NAPALM_MAJOR = 0
except ImportError:
    HAS_NAPALM = False
    try:
        import napalm_base
        HAS_NAPALM_BASE = True
    except ImportError:
        HAS_NAPALM_BASE = False

try:
    # try importing ConnectionClosedException
    # from napalm-base
    # this exception has been introduced only in version 0.24.0
    from napalm_base.exceptions import ConnectionClosedException
    HAS_CONN_CLOSED_EXC_CLASS = True
except ImportError:
    HAS_CONN_CLOSED_EXC_CLASS = False

log = logging.getLogger(__file__)


def is_proxy(opts):
    '''
    Is this a NAPALM proxy?
    '''
    return salt.utils.platform.is_proxy() and opts.get('proxy', {}).get('proxytype') == 'napalm'


def is_always_alive(opts):
    '''
    Is always alive required?
    '''
    return opts.get('proxy', {}).get('always_alive', True)


def not_always_alive(opts):
    '''
    Should this proxy be always alive?
    '''
    return (is_proxy(opts) and not is_always_alive(opts)) or is_minion(opts)


def is_minion(opts):
    '''
    Is this a NAPALM straight minion?
    '''
    return not salt.utils.platform.is_proxy() and 'napalm' in opts


def virtual(opts, virtualname, filename):
    '''
    Returns the __virtual__.
    '''
    if ((HAS_NAPALM and NAPALM_MAJOR >= 2) or HAS_NAPALM_BASE) and (is_proxy(opts) or is_minion(opts)):
        return virtualname
    else:
        return (
            False,
            (
                '"{vname}"" {filename} cannot be loaded: '
                'NAPALM is not installed: ``pip install napalm``'
            ).format(
                vname=virtualname,
                filename='({filename})'.format(filename=filename)
            )
        )


def call(napalm_device, method, *args, **kwargs):
    '''
    Calls arbitrary methods from the network driver instance.
    Please check the readthedocs_ page for the updated list of getters.

    .. _readthedocs: http://napalm.readthedocs.org/en/latest/support/index.html#getters-support-matrix

    method
        Specifies the name of the method to be called.

    *args
        Arguments.

    **kwargs
        More arguments.

    :return: A dictionary with three keys:

        * result (True/False): if the operation succeeded
        * out (object): returns the object as-is from the call
        * comment (string): provides more details in case the call failed
        * traceback (string): complete traceback in case of exception. \
        Please submit an issue including this traceback \
        on the `correct driver repo`_ and make sure to read the FAQ_

    .. _`correct driver repo`: https://github.com/napalm-automation/napalm/issues/new
    .. FAQ_: https://github.com/napalm-automation/napalm#faq

    Example:

    .. code-block:: python

        salt.utils.napalm.call(
            napalm_object,
            'cli',
            [
                'show version',
                'show chassis fan'
            ]
        )
    '''
    result = False
    out = None
    opts = napalm_device.get('__opts__', {})
    retry = kwargs.pop('__retry', True)  # retry executing the task?
    force_reconnect = kwargs.get('force_reconnect', False)
    if force_reconnect:
        log.debug('Forced reconnection initiated')
        log.debug('The current opts (under the proxy key):')
        log.debug(opts['proxy'])
        opts['proxy'].update(**kwargs)
        log.debug('Updated to:')
        log.debug(opts['proxy'])
        napalm_device = get_device(opts)
    try:
        if not napalm_device.get('UP', False):
            raise Exception('not connected')
        # if connected will try to execute desired command
        kwargs_copy = {}
        kwargs_copy.update(kwargs)
        for karg, warg in six.iteritems(kwargs_copy):
            # lets clear None arguments
            # to not be sent to NAPALM methods
            if warg is None:
                kwargs.pop(karg)
        out = getattr(napalm_device.get('DRIVER'), method)(*args, **kwargs)
        # calls the method with the specified parameters
        result = True
    except Exception as error:  # pylint: disable=broad-except
        # either not connected
        # either unable to execute the command
        hostname = napalm_device.get('HOSTNAME', '[unspecified hostname]')
        err_tb = traceback.format_exc()  # let's get the full traceback and display for debugging reasons.
        if isinstance(error, NotImplementedError):
            comment = '{method} is not implemented for the NAPALM {driver} driver!'.format(
                method=method,
                driver=napalm_device.get('DRIVER_NAME')
            )
        elif retry and HAS_CONN_CLOSED_EXC_CLASS and isinstance(error, ConnectionClosedException):
            # Received disconection whilst executing the operation.
            # Instructed to retry (default behaviour)
            #   thus trying to re-establish the connection
            #   and re-execute the command
            #   if any of the operations (close, open, call) will rise again ConnectionClosedException
            #   it will fail loudly.
            kwargs['__retry'] = False  # do not attempt re-executing
            comment = 'Disconnected from {device}. Trying to reconnect.'.format(device=hostname)
            log.error(err_tb)
            log.error(comment)
            log.debug('Clearing the connection with %s', hostname)
            call(napalm_device, 'close', __retry=False)  # safely close the connection
            # Make sure we don't leave any TCP connection open behind
            #   if we fail to close properly, we might not be able to access the
            log.debug('Re-opening the connection with %s', hostname)
            call(napalm_device, 'open', __retry=False)
            log.debug('Connection re-opened with %s', hostname)
            log.debug('Re-executing %s', method)
            return call(napalm_device, method, *args, **kwargs)
            # If still not able to reconnect and execute the task,
            #   the proxy keepalive feature (if enabled) will attempt
            #   to reconnect.
            # If the device is using a SSH-based connection, the failure
            #   will also notify the paramiko transport and the `is_alive` flag
            #   is going to be set correctly.
            # More background: the network device may decide to disconnect,
            #   although the SSH session itself is alive and usable, the reason
            #   being the lack of activity on the CLI.
            #   Paramiko's keepalive doesn't help in this case, as the ServerAliveInterval
            #   are targeting the transport layer, whilst the device takes the decision
            #   when there isn't any activity on the CLI, thus at the application layer.
            #   Moreover, the disconnect is silent and paramiko's is_alive flag will
            #   continue to return True, although the connection is already unusable.
            #   For more info, see https://github.com/paramiko/paramiko/issues/813.
            #   But after a command fails, the `is_alive` flag becomes aware of these
            #   changes and will return False from there on. And this is how the
            #   Salt proxy keepalive helps: immediately after the first failure, it
            #   will know the state of the connection and will try reconnecting.
        else:
            comment = 'Cannot execute "{method}" on {device}{port} as {user}. Reason: {error}!'.format(
                device=napalm_device.get('HOSTNAME', '[unspecified hostname]'),
                port=(':{port}'.format(port=napalm_device.get('OPTIONAL_ARGS', {}).get('port'))
                      if napalm_device.get('OPTIONAL_ARGS', {}).get('port') else ''),
                user=napalm_device.get('USERNAME', ''),
                method=method,
                error=error
            )
        log.error(comment)
        log.error(err_tb)
        return {
            'out': {},
            'result': False,
            'comment': comment,
            'traceback': err_tb
        }
    finally:
        if opts and not_always_alive(opts) and napalm_device.get('CLOSE', True):
            # either running in a not-always-alive proxy
            # either running in a regular minion
            # close the connection when the call is over
            # unless the CLOSE is explicitly set as False
            napalm_device['DRIVER'].close()
    return {
        'out': out,
        'result': result,
        'comment': ''
    }


def get_device_opts(opts, salt_obj=None):
    '''
    Returns the options of the napalm device.
    :pram: opts
    :return: the network device opts
    '''
    network_device = {}
    # by default, look in the proxy config details
    device_dict = opts.get('proxy', {}) if is_proxy(opts) else opts.get('napalm', {})
    if opts.get('proxy') or opts.get('napalm'):
        opts['multiprocessing'] = device_dict.get('multiprocessing', False)
        # Most NAPALM drivers are SSH-based, so multiprocessing should default to False.
        # But the user can be allows one to have a different value for the multiprocessing, which will
        #   override the opts.
    if not device_dict:
        # still not able to setup
        log.error('Incorrect minion config. Please specify at least the napalm driver name!')
    # either under the proxy hier, either under the napalm in the config file
    network_device['HOSTNAME'] = device_dict.get('host') or \
                                 device_dict.get('hostname') or \
                                 device_dict.get('fqdn') or \
                                 device_dict.get('ip')
    network_device['USERNAME'] = device_dict.get('username') or \
                                 device_dict.get('user')
    network_device['DRIVER_NAME'] = device_dict.get('driver') or \
                                    device_dict.get('os')
    network_device['PASSWORD'] = device_dict.get('passwd') or \
                                 device_dict.get('password') or \
                                 device_dict.get('pass') or \
                                 ''
    network_device['TIMEOUT'] = device_dict.get('timeout', 60)
    network_device['OPTIONAL_ARGS'] = device_dict.get('optional_args', {})
    network_device['ALWAYS_ALIVE'] = device_dict.get('always_alive', True)
    network_device['PROVIDER'] = device_dict.get('provider')
    network_device['UP'] = False
    # get driver object form NAPALM
    if 'config_lock' not in network_device['OPTIONAL_ARGS']:
        network_device['OPTIONAL_ARGS']['config_lock'] = False
    if network_device['ALWAYS_ALIVE'] and 'keepalive' not in network_device['OPTIONAL_ARGS']:
        network_device['OPTIONAL_ARGS']['keepalive'] = 5  # 5 seconds keepalive
    return network_device


def get_device(opts, salt_obj=None):
    '''
    Initialise the connection with the network device through NAPALM.
    :param: opts
    :return: the network device object
    '''
    log.debug('Setting up NAPALM connection')
    network_device = get_device_opts(opts, salt_obj=salt_obj)
    provider_lib = napalm_base
    if network_device.get('PROVIDER'):
        # In case the user requires a different provider library,
        #   other than napalm-base.
        # For example, if napalm-base does not satisfy the requirements
        #   and needs to be enahanced with more specific features,
        #   we may need to define a custom library on top of napalm-base
        #   with the constraint that it still needs to provide the
        #   `get_network_driver` function. However, even this can be
        #   extended later, if really needed.
        # Configuration example:
        #   provider: napalm_base_example
        try:
            provider_lib = importlib.import_module(network_device.get('PROVIDER'))
        except ImportError as ierr:
            log.error('Unable to import %s',
                      network_device.get('PROVIDER'),
                      exc_info=True)
            log.error('Falling back to napalm-base')
    _driver_ = provider_lib.get_network_driver(network_device.get('DRIVER_NAME'))
    try:
        network_device['DRIVER'] = _driver_(
            network_device.get('HOSTNAME', ''),
            network_device.get('USERNAME', ''),
            network_device.get('PASSWORD', ''),
            timeout=network_device['TIMEOUT'],
            optional_args=network_device['OPTIONAL_ARGS']
        )
        network_device.get('DRIVER').open()
        # no exception raised here, means connection established
        network_device['UP'] = True
    except napalm_base.exceptions.ConnectionException as error:
        base_err_msg = "Cannot connect to {hostname}{port} as {username}.".format(
            hostname=network_device.get('HOSTNAME', '[unspecified hostname]'),
            port=(':{port}'.format(port=network_device.get('OPTIONAL_ARGS', {}).get('port'))
                  if network_device.get('OPTIONAL_ARGS', {}).get('port') else ''),
            username=network_device.get('USERNAME', '')
        )
        log.error(base_err_msg)
        log.error(
            "Please check error: %s", error
        )
        raise napalm_base.exceptions.ConnectionException(base_err_msg)
    return network_device


def proxy_napalm_wrap(func):
    '''
    This decorator is used to make the execution module functions
    available outside a proxy minion, or when running inside a proxy
    minion. If we are running in a proxy, retrieve the connection details
    from the __proxy__ injected variable.  If we are not, then
    use the connection information from the opts.
    :param func:
    :return:
    '''
    @wraps(func)
    def func_wrapper(*args, **kwargs):
        wrapped_global_namespace = func.__globals__
        # get __opts__ and __proxy__ from func_globals
        proxy = wrapped_global_namespace.get('__proxy__')
        opts = copy.deepcopy(wrapped_global_namespace.get('__opts__'))
        # in any case, will inject the `napalm_device` global
        # the execution modules will make use of this variable from now on
        # previously they were accessing the device properties through the __proxy__ object
        always_alive = opts.get('proxy', {}).get('always_alive', True)
        # force_reconnect is a magic keyword arg that allows one to establish
        # a separate connection to the network device running under an always
        # alive Proxy Minion, using new credentials (overriding the ones
        # configured in the opts / pillar.
        force_reconnect = kwargs.get('force_reconnect', False)
        if force_reconnect:
            log.debug('Usage of reconnect force detected')
            log.debug('Opts before merging')
            log.debug(opts['proxy'])
            opts['proxy'].update(**kwargs)
            log.debug('Opts after merging')
            log.debug(opts['proxy'])
        if is_proxy(opts) and always_alive:
            # if it is running in a NAPALM Proxy and it's using the default
            # always alive behaviour, will get the cached copy of the network
            # device object which should preserve the connection.
            if force_reconnect:
                wrapped_global_namespace['napalm_device'] = get_device(opts)
            else:
                wrapped_global_namespace['napalm_device'] = proxy['napalm.get_device']()
        elif is_proxy(opts) and not always_alive:
            # if still proxy, but the user does not want the SSH session always alive
            # get a new device instance
            # which establishes a new connection
            # which is closed just before the call() function defined above returns
            if 'inherit_napalm_device' not in kwargs or ('inherit_napalm_device' in kwargs and
                                                         not kwargs['inherit_napalm_device']):
                # try to open a new connection
                # but only if the function does not inherit the napalm driver
                # for configuration management this is very important,
                # in order to make sure we are editing the same session.
                try:
                    wrapped_global_namespace['napalm_device'] = get_device(opts)
                except napalm_base.exceptions.ConnectionException as nce:
                    log.error(nce)
                    return '{base_msg}. See log for details.'.format(
                        base_msg=six.text_type(nce.msg)
                    )
            else:
                # in case the `inherit_napalm_device` is set
                # and it also has a non-empty value,
                # the global var `napalm_device` will be overridden.
                # this is extremely important for configuration-related features
                # as all actions must be issued within the same configuration session
                # otherwise we risk to open multiple sessions
                wrapped_global_namespace['napalm_device'] = kwargs['inherit_napalm_device']
        else:
            # if not a NAPLAM proxy
            # thus it is running on a regular minion, directly on the network device
            # or another flavour of Minion from where we can invoke arbitrary
            # NAPALM commands
            # get __salt__ from func_globals
            log.debug('Not running in a NAPALM Proxy Minion')
            _salt_obj = wrapped_global_namespace.get('__salt__')
            napalm_opts = _salt_obj['config.get']('napalm', {})
            napalm_inventory = _salt_obj['config.get']('napalm_inventory', {})
            log.debug('NAPALM opts found in the Minion config')
            log.debug(napalm_opts)
            clean_kwargs = salt.utils.args.clean_kwargs(**kwargs)
            napalm_opts.update(clean_kwargs)  # no need for deeper merge
            log.debug('Merging the found opts with the CLI args')
            log.debug(napalm_opts)
            host = napalm_opts.get('host') or napalm_opts.get('hostname') or\
                   napalm_opts.get('fqdn') or napalm_opts.get('ip')
            if host and napalm_inventory and isinstance(napalm_inventory, dict) and\
               host in napalm_inventory:
                inventory_opts = napalm_inventory[host]
                log.debug('Found %s in the NAPALM inventory:', host)
                log.debug(inventory_opts)
                napalm_opts.update(inventory_opts)
                log.debug('Merging the config for %s with the details found in the napalm inventory:', host)
                log.debug(napalm_opts)
            opts = copy.deepcopy(opts)  # make sure we don't override the original
            # opts, but just inject the CLI args from the kwargs to into the
            # object manipulated by ``get_device_opts`` to extract the
            # connection details, then use then to establish the connection.
            opts['napalm'] = napalm_opts
            if 'inherit_napalm_device' not in kwargs or ('inherit_napalm_device' in kwargs and
                                                         not kwargs['inherit_napalm_device']):
                # try to open a new connection
                # but only if the function does not inherit the napalm driver
                # for configuration management this is very important,
                # in order to make sure we are editing the same session.
                try:
                    wrapped_global_namespace['napalm_device'] = get_device(opts, salt_obj=_salt_obj)
                except napalm_base.exceptions.ConnectionException as nce:
                    log.error(nce)
                    return '{base_msg}. See log for details.'.format(
                        base_msg=six.text_type(nce.msg)
                    )
            else:
                # in case the `inherit_napalm_device` is set
                # and it also has a non-empty value,
                # the global var `napalm_device` will be overridden.
                # this is extremely important for configuration-related features
                # as all actions must be issued within the same configuration session
                # otherwise we risk to open multiple sessions
                wrapped_global_namespace['napalm_device'] = kwargs['inherit_napalm_device']
        if not_always_alive(opts):
            # inject the __opts__ only when not always alive
            # otherwise, we don't want to overload the always-alive proxies
            wrapped_global_namespace['napalm_device']['__opts__'] = opts
        ret = func(*args, **kwargs)
        if force_reconnect:
            log.debug('That was a forced reconnect, gracefully clearing up')
            device = wrapped_global_namespace['napalm_device']
            closing = call(device, 'close', __retry=False)
        return ret
    return func_wrapper


def default_ret(name):
    '''
    Return the default dict of the state output.
    '''
    ret = {
        'name': name,
        'changes': {},
        'result': False,
        'comment': ''
    }
    return ret


def loaded_ret(ret, loaded, test, debug, compliance_report=False, opts=None):
    '''
    Return the final state output.
    ret
        The initial state output structure.
    loaded
        The loaded dictionary.
    '''
    # Always get the comment
    changes = {}
    ret['comment'] = loaded['comment']
    if 'diff' in loaded:
        changes['diff'] = loaded['diff']
    if 'commit_id' in loaded:
        changes['commit_id'] = loaded['commit_id']
    if 'compliance_report' in loaded:
        if compliance_report:
            changes['compliance_report'] = loaded['compliance_report']
    if debug and 'loaded_config' in loaded:
        changes['loaded_config'] = loaded['loaded_config']
    if changes.get('diff'):
        ret['comment'] = '{comment_base}\n\nConfiguration diff:\n\n{diff}'.format(comment_base=ret['comment'],
                                                                                  diff=changes['diff'])
    if changes.get('loaded_config'):
        ret['comment'] = '{comment_base}\n\nLoaded config:\n\n{loaded_cfg}'.format(
            comment_base=ret['comment'],
            loaded_cfg=changes['loaded_config'])
    if changes.get('compliance_report'):
        ret['comment'] = '{comment_base}\n\nCompliance report:\n\n{compliance}'.format(
            comment_base=ret['comment'],
            compliance=salt.output.string_format(changes['compliance_report'], 'nested', opts=opts))
    if not loaded.get('result', False):
        # Failure of some sort
        return ret
    if not loaded.get('already_configured', True):
        # We're making changes
        if test:
            ret['result'] = None
            return ret
        # Not test, changes were applied
        ret.update({
            'result': True,
            'changes': changes,
            'comment': "Configuration changed!\n{}".format(loaded['comment'])
        })
        return ret
    # No changes
    ret.update({
        'result': True,
        'changes': {}
    })
    return ret
