"""
Watch NAPALM functions and fire events on specific triggers
===========================================================

.. versionadded:: 2018.3.0


.. note::

    The ``NAPALM`` beacon only works only when running under
    a regular Minion or a Proxy Minion, managed via NAPALM_.
    Check the documentation for the
    :mod:`NAPALM proxy module <salt.proxy.napalm>`.

    .. _NAPALM: http://napalm.readthedocs.io/en/latest/index.html

The configuration accepts a list of Salt functions to be
invoked, and the corresponding output hierarchy that should
be matched against. To invoke a function with certain
arguments, they can be specified using the ``_args`` key, or
``_kwargs`` for more specific key-value arguments.

The match structure follows the output hierarchy of the NAPALM
functions, under the ``out`` key.

For example, the following is normal structure returned by the
:mod:`ntp.stats <salt.modules.napalm_ntp.stats>` execution function:

.. code-block:: json

    {
        "comment": "",
        "result": true,
        "out": [
            {
                "referenceid": ".GPSs.",
                "remote": "172.17.17.1",
                "synchronized": true,
                "reachability": 377,
                "offset": 0.461,
                "when": "860",
                "delay": 143.606,
                "hostpoll": 1024,
                "stratum": 1,
                "jitter": 0.027,
                "type": "-"
            },
            {
                "referenceid": ".INIT.",
                "remote": "172.17.17.2",
                "synchronized": false,
                "reachability": 0,
                "offset": 0.0,
                "when": "-",
                "delay": 0.0,
                "hostpoll": 1024,
                "stratum": 16,
                "jitter": 4000.0,
                "type": "-"
            }
        ]
    }

In order to fire events when the synchronization is lost with
one of the NTP peers, e.g., ``172.17.17.2``, we can match it explicitly as:

.. code-block:: yaml

    ntp.stats:
      remote: 172.17.17.2
      synchronized: false

There is one single nesting level, as the output of ``ntp.stats`` is
just a list of dictionaries, and this beacon will compare each dictionary
from the list with the structure examplified above.

.. note::

    When we want to match on any element at a certain level, we can
    configure ``*`` to match anything.

Considering a more complex structure consisting on multiple nested levels,
e.g., the output of the :mod:`bgp.neighbors <salt.modules.napalm_bgp.neighbors>`
execution function, to check when any neighbor from the ``global``
routing table is down, the match structure would have the format:

.. code-block:: yaml

    bgp.neighbors:
      global:
        '*':
          up: false

The match structure above will match any BGP neighbor, with
any network (``*`` matches any AS number), under the ``global`` VRF.
In other words, this beacon will push an event on the Salt bus
when there's a BGP neighbor down.

The right operand can also accept mathematical operations
(i.e., ``<``, ``<=``, ``!=``, ``>``, ``>=`` etc.) when comparing
numerical values.

Configuration Example:

.. code-block:: yaml

    beacons:
      napalm:
        - net.interfaces:
            # fire events when any interfaces is down
            '*':
              is_up: false
        - net.interfaces:
            # fire events only when the xe-0/0/0 interface is down
            'xe-0/0/0':
              is_up: false
        - ntp.stats:
            # fire when there's any NTP peer unsynchornized
            synchronized: false
        - ntp.stats:
            # fire only when the synchronization
            # with with the 172.17.17.2 NTP server is lost
            _args:
              - 172.17.17.2
            synchronized: false
        - ntp.stats:
            # fire only when there's a NTP peer with
            # synchronization stratum > 5
            stratum: '> 5'

Event structure example:

.. code-block:: json

    {
        "_stamp": "2017-09-05T09:51:09.377202",
        "args": [],
        "data": {
            "comment": "",
            "out": [
                {
                    "delay": 0.0,
                    "hostpoll": 1024,
                    "jitter": 4000.0,
                    "offset": 0.0,
                    "reachability": 0,
                    "referenceid": ".INIT.",
                    "remote": "172.17.17.1",
                    "stratum": 16,
                    "synchronized": false,
                    "type": "-",
                    "when": "-"
                }
            ],
            "result": true
        },
        "fun": "ntp.stats",
        "id": "edge01.bjm01",
        "kwargs": {},
        "match": {
            "stratum": "> 5"
        }
    }

The event examplified above has been fired when the device
identified by the Minion id ``edge01.bjm01`` has been synchronized
with a NTP server at a stratum level greater than 5.
"""

import logging
import re

import salt.utils.beacons
import salt.utils.napalm

log = logging.getLogger(__name__)
_numeric_regex = re.compile(r"^(<|>|<=|>=|==|!=)\s*(\d+(\.\d+){0,1})$")
# the numeric regex will match the right operand, e.g '>= 20', '< 100', '!= 20', '< 1000.12' etc.
_numeric_operand = {
    "<": "__lt__",
    ">": "__gt__",
    ">=": "__ge__",
    "<=": "__le__",
    "==": "__eq__",
    "!=": "__ne__",
}  # mathematical operand - private method map


__virtualname__ = "napalm"


def __virtual__():
    """
    This beacon can only work when running under a regular or a proxy minion, managed through napalm.
    """
    if salt.utils.napalm.virtual(__opts__, __virtualname__, __file__):
        return __virtualname__
    else:
        err_msg = "NAPALM is not installed."
        log.error("Unable to load %s beacon: %s", __virtualname__, err_msg)
        return False, err_msg


def _compare(cur_cmp, cur_struct):
    """
    Compares two objects and return a boolean value
    when there's a match.
    """
    if isinstance(cur_cmp, dict) and isinstance(cur_struct, dict):
        log.debug("Comparing dict to dict")
        for cmp_key, cmp_value in cur_cmp.items():
            if cmp_key == "*":
                # matches any key from the source dictionary
                if isinstance(cmp_value, dict):
                    found = False
                    for _, cur_struct_val in cur_struct.items():
                        found |= _compare(cmp_value, cur_struct_val)
                    return found
                else:
                    found = False
                    if isinstance(cur_struct, (list, tuple)):
                        for cur_ele in cur_struct:
                            found |= _compare(cmp_value, cur_ele)
                    elif isinstance(cur_struct, dict):
                        for _, cur_ele in cur_struct.items():
                            found |= _compare(cmp_value, cur_ele)
                    return found
            else:
                if isinstance(cmp_value, dict):
                    if cmp_key not in cur_struct:
                        return False
                    return _compare(cmp_value, cur_struct[cmp_key])
                if isinstance(cmp_value, list):
                    found = False
                    for _, cur_struct_val in cur_struct.items():
                        found |= _compare(cmp_value, cur_struct_val)
                    return found
                else:
                    return _compare(cmp_value, cur_struct[cmp_key])
    elif isinstance(cur_cmp, (list, tuple)) and isinstance(cur_struct, (list, tuple)):
        log.debug("Comparing list to list")
        found = False
        for cur_cmp_ele in cur_cmp:
            for cur_struct_ele in cur_struct:
                found |= _compare(cur_cmp_ele, cur_struct_ele)
        return found
    elif isinstance(cur_cmp, dict) and isinstance(cur_struct, (list, tuple)):
        log.debug("Comparing dict to list (of dicts?)")
        found = False
        for cur_struct_ele in cur_struct:
            found |= _compare(cur_cmp, cur_struct_ele)
        return found
    elif isinstance(cur_cmp, bool) and isinstance(cur_struct, bool):
        log.debug("Comparing booleans: %s ? %s", cur_cmp, cur_struct)
        return cur_cmp == cur_struct
    elif isinstance(cur_cmp, ((str,), str)) and isinstance(cur_struct, ((str,), str)):
        log.debug("Comparing strings (and regex?): %s ? %s", cur_cmp, cur_struct)
        # Trying literal match
        matched = re.match(cur_cmp, cur_struct, re.I)
        if matched:
            return True
        return False
    elif isinstance(cur_cmp, ((int,), float)) and isinstance(
        cur_struct, ((int,), float)
    ):
        log.debug("Comparing numeric values: %d ? %d", cur_cmp, cur_struct)
        # numeric compare
        return cur_cmp == cur_struct
    elif isinstance(cur_struct, ((int,), float)) and isinstance(cur_cmp, ((str,), str)):
        # Comparing the numerical value against a presumably mathematical value
        log.debug(
            "Comparing a numeric value (%d) with a string (%s)", cur_struct, cur_cmp
        )
        numeric_compare = _numeric_regex.match(cur_cmp)
        # determine if the value to compare against is a mathematical operand
        if numeric_compare:
            compare_value = numeric_compare.group(2)
            return getattr(
                float(cur_struct), _numeric_operand[numeric_compare.group(1)]
            )(float(compare_value))
        return False
    return False


def validate(config):
    """
    Validate the beacon configuration.
    """
    # Must be a list of dicts.
    if not isinstance(config, list):
        return False, "Configuration for napalm beacon must be a list."
    for mod in config:
        fun, fun_cfg = next(iter(mod.items()))
        if not isinstance(fun_cfg, dict):
            return (
                False,
                "The match structure for the {} execution function output must be a"
                " dictionary".format(fun),
            )
        if fun not in __salt__:
            return False, f"Execution function {fun} is not availabe!"
    return True, "Valid configuration for the napal beacon!"


def beacon(config):
    """
    Watch napalm function and fire events.
    """
    whitelist = []
    config = salt.utils.beacons.remove_hidden_options(config, whitelist)

    log.debug("Executing napalm beacon with config:")
    log.debug(config)
    ret = []
    for mod in config:
        if not mod:
            continue
        event = {}
        fun, fun_cfg = next(iter(mod.items()))
        args = fun_cfg.pop("_args", [])
        kwargs = fun_cfg.pop("_kwargs", {})
        log.debug("Executing %s with %s and %s", fun, args, kwargs)
        fun_ret = __salt__[fun](*args, **kwargs)
        log.debug("Got the reply from the minion:")
        log.debug(fun_ret)
        if not fun_ret.get("result", False):
            log.error("Error whilst executing %s", fun)
            log.error(fun_ret)
            continue
        fun_ret_out = fun_ret["out"]
        log.debug("Comparing to:")
        log.debug(fun_cfg)
        try:
            fun_cmp_result = _compare(fun_cfg, fun_ret_out)
        except Exception as err:  # pylint: disable=broad-except
            log.error(err, exc_info=True)
            # catch any exception and continue
            # to not jeopardise the execution of the next function in the list
            continue
        log.debug("Result of comparison: %s", fun_cmp_result)
        if fun_cmp_result:
            log.info("Matched %s with %s", fun, fun_cfg)
            event["tag"] = "{os}/{fun}".format(os=__grains__["os"], fun=fun)
            event["fun"] = fun
            event["args"] = args
            event["kwargs"] = kwargs
            event["data"] = fun_ret
            event["match"] = fun_cfg
            log.debug("Queueing event:")
            log.debug(event)
            ret.append(event)
    log.debug("NAPALM beacon generated the events:")
    log.debug(ret)
    return ret
