# -*- coding: utf-8 -*-
'''
Network SNMP
============

Manage the SNMP configuration on network devices.

:codeauthor: Mircea Ulinic <mircea@cloudflare.com>
:maturity:   new
:depends:    napalm
:platform:   unix

Dependencies
------------

- :mod:`napalm snmp management module (salt.modules.napalm_snmp) <salt.modules.napalm_snmp>`

.. versionadded: 2016.11.0
'''

from __future__ import absolute_import, print_function, unicode_literals

import logging
log = logging.getLogger(__name__)

from json import loads, dumps

# salt lib
from salt.ext import six
# import NAPALM utils
import salt.utils.napalm

# ----------------------------------------------------------------------------------------------------------------------
# state properties
# ----------------------------------------------------------------------------------------------------------------------

__virtualname__ = 'netsnmp'

_COMMUNITY_MODE_MAP = {
    'read-only': 'ro',
    'readonly': 'ro',
    'read-write': 'rw',
    'write': 'rw'
}

# ----------------------------------------------------------------------------------------------------------------------
# global variables
# ----------------------------------------------------------------------------------------------------------------------

# ----------------------------------------------------------------------------------------------------------------------
# property functions
# ----------------------------------------------------------------------------------------------------------------------


def __virtual__():
    '''
    NAPALM library must be installed for this module to work and run in a (proxy) minion.
    '''
    return salt.utils.napalm.virtual(__opts__, __virtualname__, __file__)

# ----------------------------------------------------------------------------------------------------------------------
# helper functions -- will not be exported
# ----------------------------------------------------------------------------------------------------------------------


def _ordered_dict_to_dict(config):

    '''
    Forced the datatype to dict, in case OrderedDict is used.
    '''

    return loads(dumps(config))


def _expand_config(config, defaults):

    '''
    Completed the values of the expected config for the edge cases with the default values.
    '''

    defaults.update(config)
    return defaults


def _valid_dict(dic):

    '''
    Valid dictionary?
    '''

    return isinstance(dic, dict) and len(dic) > 0


def _valid_str(value):

    '''
    Valid str?
    '''

    return isinstance(value, six.string_types) and len(value) > 0


def _community_defaults():

    '''
    Returns the default values of a community.
    '''

    return {
        'mode': 'ro'
    }


def _clear_community_details(community_details):

    '''
    Clears community details.
    '''

    for key in ['acl', 'mode']:
        _str_elem(community_details, key)

    _mode = community_details.get['mode'] = community_details.get('mode').lower()

    if _mode in _COMMUNITY_MODE_MAP.keys():
        community_details['mode'] = _COMMUNITY_MODE_MAP.get(_mode)

    if community_details['mode'] not in ['ro', 'rw']:
        community_details['mode'] = 'ro'  # default is read-only

    return community_details


def _str_elem(config, key):

    '''
    Re-adds the value of a specific key in the dict, only in case of valid str value.
    '''

    _value = config.pop(key, '')
    if _valid_str(_value):
        config[key] = _value


def _check_config(config):

    '''
    Checks the desired config and clears interesting details.
    '''

    if not _valid_dict(config):
        return True, ''

    _community = config.get('community')
    _community_tmp = {}
    if not _community:
        return False, 'Must specify at least a community.'
    if _valid_str(_community):
        _community_tmp[_community] = _community_defaults()
    elif isinstance(_community, list):
        # if the user specifies the communities as list
        for _comm in _community:
            if _valid_str(_comm):
                # list of values
                _community_tmp[_comm] = _community_defaults()
                # default mode is read-only
            if _valid_dict(_comm):
                # list of dicts
                for _comm_name, _comm_details in six.iteritems(_comm):
                    if _valid_str(_comm_name):
                        _community_tmp[_comm_name] = _clear_community_details(_comm_details)
    elif _valid_dict(_community):
        # directly as dict of communities
        # recommended way...
        for _comm_name, _comm_details in six.iteritems(_community):
            if _valid_str(_comm_name):
                _community_tmp[_comm_name] = _clear_community_details(_comm_details)
    else:
        return False, 'Please specify a community or a list of communities.'

    if not _valid_dict(_community_tmp):
        return False, 'Please specify at least a valid community!'

    config['community'] = _community_tmp

    for key in ['location', 'contact', 'chassis_id']:
        # not mandatory, but should be here only if valid
        _str_elem(config, key)

    return True, ''


def _retrieve_device_config():

    '''
    Retrieves the SNMP config from the device.
    '''

    return __salt__['snmp.config']()


def _create_diff_action(diff, diff_key, key, value):

    '''
    DRY to build diff parts (added, removed, updated).
    '''

    if diff_key not in diff.keys():
        diff[diff_key] = {}
    diff[diff_key][key] = value


def _create_diff(diff, fun, key, prev, curr):

    '''
    Builds the diff dictionary.
    '''

    if not fun(prev):
        _create_diff_action(diff, 'added', key, curr)
    elif fun(prev) and not fun(curr):
        _create_diff_action(diff, 'removed', key, prev)
    elif not fun(curr):
        _create_diff_action(diff, 'updated', key, curr)


def _compute_diff(existing, expected):

    '''
    Computes the differences between the existing and the expected SNMP config.
    '''

    diff = {}

    for key in ['location', 'contact', 'chassis_id']:
        if existing.get(key) != expected.get(key):
            _create_diff(diff,
                         _valid_str,
                         key,
                         existing.get(key),
                         expected.get(key))

    for key in ['community']:  # for the moment only onen
        if existing.get(key) != expected.get(key):
            _create_diff(diff,
                         _valid_dict,
                         key,
                         existing.get(key),
                         expected.get(key))

    return diff


def _configure(changes):

    '''
    Calls the configuration template to apply the configuration changes on the device.
    '''

    cfgred = True
    reasons = []
    fun = 'update_config'

    for key in ['added', 'updated', 'removed']:
        _updated_changes = changes.get(key, {})
        if not _updated_changes:
            continue
        _location = _updated_changes.get('location', '')
        _contact = _updated_changes.get('contact', '')
        _community = _updated_changes.get('community', {})
        _chassis_id = _updated_changes.get('chassis_id', '')
        if key == 'removed':
            fun = 'remove_config'
        _ret = __salt__['snmp.{fun}'.format(fun=fun)](location=_location,
                                                      contact=_contact,
                                                      community=_community,
                                                      chassis_id=_chassis_id,
                                                      commit=False)
        cfgred = cfgred and _ret.get('result')
        if not _ret.get('result') and _ret.get('comment'):
            reasons.append(_ret.get('comment'))

    return {
        'result': cfgred,
        'comment': '\n'.join(reasons) if reasons else ''
    }

# ----------------------------------------------------------------------------------------------------------------------
# callable functions
# ----------------------------------------------------------------------------------------------------------------------


def managed(name, config=None, defaults=None):

    '''
    Configures the SNMP on the device as specified in the SLS file.

    SLS Example:

    .. code-block:: yaml

        snmp_example:
            netsnmp.managed:
                 - config:
                    location: Honolulu, HI, US
                 - defaults:
                    contact: noc@cloudflare.com

    Output example (for the SLS above, e.g. called snmp.sls under /router/):

    .. code-block:: bash

        $ sudo salt edge01.hnl01 state.sls router.snmp test=True
        edge01.hnl01:
        ----------
                  ID: snmp_example
            Function: snmp.managed
              Result: None
             Comment: Testing mode: configuration was not changed!
             Started: 13:29:06.872363
            Duration: 920.466 ms
             Changes:
                      ----------
                      added:
                          ----------
                          chassis_id:
                              None
                          contact:
                              noc@cloudflare.com
                          location:
                              Honolulu, HI, US

        Summary for edge01.hnl01
        ------------
        Succeeded: 1 (unchanged=1, changed=1)
        Failed:    0
        ------------
        Total states run:     1
        Total run time: 920.466 ms
    '''

    result = False
    comment = ''
    changes = {}

    ret = {
        'name': name,
        'changes': changes,
        'result': result,
        'comment': comment
    }

    # make sure we're working only with dict
    config = _ordered_dict_to_dict(config)
    defaults = _ordered_dict_to_dict(defaults)

    expected_config = _expand_config(config, defaults)
    if not isinstance(expected_config, dict):
        ret['comment'] = 'User provided an empty SNMP config!'
        return ret
    valid, message = _check_config(expected_config)

    if not valid:  # check and clean
        ret['comment'] = 'Please provide a valid configuration: {error}'.format(error=message)
        return ret

    # ----- Retrieve existing users configuration and determine differences ------------------------------------------->

    _device_config = _retrieve_device_config()
    if not _device_config.get('result'):
        ret['comment'] = 'Cannot retrieve SNMP config from the device: {reason}'.format(
            reason=_device_config.get('comment')
        )
        return ret

    device_config = _device_config.get('out', {})

    if device_config == expected_config:
        ret.update({
            'comment': 'SNMP already configured as needed.',
            'result': True
        })
        return ret

    diff = _compute_diff(device_config, expected_config)

    changes.update(diff)

    ret.update({
        'changes': changes
    })

    if __opts__['test'] is True:
        ret.update({
            'result': None,
            'comment': 'Testing mode: configuration was not changed!'
        })
        return ret

    # <---- Retrieve existing NTP peers and determine peers to be added/removed --------------------------------------->

    # ----- Call _set_users and _delete_users as needed ------------------------------------------------------->

    expected_config_change = False
    result = True

    if diff:
        _configured = _configure(diff)
        if _configured.get('result'):
            expected_config_change = True
        else:  # something went wrong...
            result = False
            comment = 'Cannot push new SNMP config: \n{reason}'.format(
                reason=_configured.get('comment')
            ) +  comment

    if expected_config_change:
        result, comment = __salt__['net.config_control']()

    # <---- Call _set_users and _delete_users as needed --------------------------------------------------------

    ret.update({
        'result': result,
        'comment': comment
    })

    return ret
