#!/usr/lib64/linuxfabrik-monitoring-plugins/venv/bin/python
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author:  Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
#          https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.

# https://github.com/Linuxfabrik/monitoring-plugins/blob/main/CONTRIBUTING.md

"""See the check's README for more details."""

import argparse
import json
import sys

import lib.args
import lib.base
import lib.db_mysql
import lib.human
import lib.lftest
import lib.time
import lib.txt
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN

__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
__version__ = '2026081201'

DESCRIPTION = """Retrieves hardware sensor information (temperature, humidity, voltage, power, etc.)
for each device from a LibreNMS instance. Alerts when sensor values exceed their
configured thresholds. Requires direct access to the LibreNMS MySQL/MariaDB
database.
Supports extended reporting via --lengthy."""

DEFAULT_DEFAULTS_FILE = '/var/spool/icinga2/.my.cnf'
DEFAULT_DEFAULTS_GROUP = 'client'
DEFAULT_LENGTHY = False
DEFAULT_TIMEOUT = 3

# The values LibreNMS stores in `state_generic_value`, which happen to be the Nagios
# numbering (`LibreNMS\Enum\SensorState`: 0 ok, 1 warning, 2 error, 3 unknown). Kept as a
# set rather than assumed, because a device can put anything in there and the check may
# only ever exit 0, 1, 2 or 3.
STATE_BY_GENERIC_VALUE = (STATE_OK, STATE_WARN, STATE_CRIT, STATE_UNKNOWN)

# How the table is ordered: worst first, so the row an admin has to act on is the one at
# the top. Nagios numbers UNKNOWN above CRITICAL, which is the wrong way round for
# reading - an unreadable sensor is a gap, a critical one is a fire.
STATE_SORT_RANK = {
    STATE_CRIT: 0,
    STATE_WARN: 1,
    STATE_UNKNOWN: 2,
    STATE_OK: 3,
}


def parse_args():
    """Parse command line arguments using argparse."""
    parser = argparse.ArgumentParser(
        description=DESCRIPTION,
        epilog=lib.args.epilog(__file__),
        formatter_class=lib.args.HelpFormatter,
    )

    parser.add_argument(
        '-V',
        '--version',
        action='version',
        version=f'%(prog)s: v{__version__} by {__author__}',
    )

    parser.add_argument(
        '--always-ok',
        help=lib.args.help('--always-ok'),
        dest='ALWAYS_OK',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--defaults-file',
        help=lib.args.help('--defaults-file') + ' '
        'Example: `/var/spool/icinga2/.my.cnf`. '
        'Default: %(default)s',
        dest='DEFAULTS_FILE',
        default=DEFAULT_DEFAULTS_FILE,
    )

    parser.add_argument(
        '--defaults-group',
        help=lib.args.help('--defaults-group') + ' Default: %(default)s',
        dest='DEFAULTS_GROUP',
        default=DEFAULT_DEFAULTS_GROUP,
    )

    parser.add_argument(
        '--device-group',
        help='Filter by LibreNMS device group. Supports SQL wildcards.',
        dest='DEVICE_GROUP',
    )

    parser.add_argument(
        '--device-hostname',
        help='Filter by LibreNMS hostname. Can be specified multiple times.',
        dest='DEVICE_HOSTNAME',
        action='append',
    )

    parser.add_argument(
        '--device-type',
        help='Filter by LibreNMS device type. Can be specified multiple times.',
        dest='DEVICE_TYPE',
        action='append',
        # choices from the librenms source resources/definitions/config_definitions.json
        choices=[
            'appliance',
            'collaboration',
            'environment',
            'firewall',
            'loadbalancer',
            'management',
            'network',
            'power',
            'printer',
            'server',
            'storage',
            'wireless',
            'workstation',
        ],
    )

    parser.add_argument(
        '--lengthy',
        help=lib.args.help('--lengthy'),
        dest='LENGTHY',
        action='store_true',
        default=DEFAULT_LENGTHY,
    )

    parser.add_argument(
        '--no-perfdata',
        help=lib.args.help('--no-perfdata'),
        dest='NO_PERFDATA',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--test',
        help=lib.args.help('--test'),
        dest='TEST',
        type=lib.args.csv,
    )

    parser.add_argument(
        '--timeout',
        help=lib.args.help('--timeout') + ' Default: %(default)s (seconds)',
        dest='TIMEOUT',
        type=int,
        default=DEFAULT_TIMEOUT,
    )

    args, _ = parser.parse_known_args()
    return args


def deduplicate(sensors):
    """Return one row per sensor, in two passes.

    The query joins the device groups so `--device-group` can filter on them, and that
    join multiplies every row of a device that sits in more than one group. Those copies
    are identical and are dropped on `sensor_id`.

    What survives that is a device having discovered the same physical sensor more than
    once, each copy with a `sensor_id` of its own - Cisco hardware does this. They are
    folded together on the name the sensor reports itself under, and the worst reading
    wins, so a device that reports the same sensor twice cannot hide an alert behind a
    healthy copy of itself.

    Both passes keep the order the database returned, which is what the display order
    below builds on.
    """
    by_id = {}
    for sensor in sensors:
        by_id.setdefault(sensor['sensor_id'], sensor)

    by_sensor = {}
    for sensor in by_id.values():
        key = (sensor['hostname'], sensor['sensor_descr'])
        kept = by_sensor.get(key)
        if kept is None or severity_rank(sensor) < severity_rank(kept):
            by_sensor[key] = sensor
    return list(by_sensor.values())


def get_sensor_state(sensor):
    """Determine the Nagios state for a single LibreNMS sensor row.

    This mirrors LibreNMS' own `Sensor::currentStatus()`, so the plugin agrees
    with the severity LibreNMS shows on its device health and sensors pages.

    * Discrete state-class sensors (fan ok/fail, psu present/absent, ...) carry
      a pre-computed severity in `state_generic_value`, which shares the Nagios
      numbering (0 = ok, 1 = warn, 2 = crit, 3 = unknown). A state sensor
      without a matching translation is UNKNOWN, as in LibreNMS.
    * Numeric sensors (temperature, humidity, voltage, power, ...) are compared
      against their four configured limits: CRITICAL when at or beyond the
      hard `sensor_limit` / `sensor_limit_low`, WARNING when at or beyond the
      softer `sensor_limit_warn` / `sensor_limit_low_warn`. Comparisons are
      inclusive, matching LibreNMS. A sensor without a current reading is
      UNKNOWN.
    """
    if sensor['sensor_class'] == 'state':
        generic = sensor['state_generic_value']
        if generic is None:
            return STATE_UNKNOWN
        # LibreNMS maps its own three values and calls everything else unknown
        # (`StateTranslation::severity()`), so anything a device invented is
        # reported as such rather than being passed on as an exit code that is
        # not one of the four a plugin may return.
        return int(generic) if int(generic) in STATE_BY_GENERIC_VALUE else STATE_UNKNOWN

    current = sensor['sensor_current']
    if current is None:
        return STATE_UNKNOWN
    current = float(current)

    limit = sensor['sensor_limit']
    limit_low = sensor['sensor_limit_low']
    over_crit = limit is not None and current >= float(limit)
    under_crit = limit_low is not None and current <= float(limit_low)
    if over_crit or under_crit:
        return STATE_CRIT

    limit_warn = sensor['sensor_limit_warn']
    limit_low_warn = sensor['sensor_limit_low_warn']
    over_warn = limit_warn is not None and current >= float(limit_warn)
    under_warn = limit_low_warn is not None and current <= float(limit_low_warn)
    if over_warn or under_warn:
        return STATE_WARN

    return STATE_OK


def severity_rank(sensor):
    """Return how far up the table a sensor belongs, worst first. See STATE_SORT_RANK."""
    return STATE_SORT_RANK[get_sensor_state(sensor)]


def format_sensor_range(sensor):
    """Build the `warn/crit` range annotation shown next to a sensor value.

    Renders the warning range and the critical range side by side as
    `low_warn..high_warn/low_crit..high_crit`, matching the
    `Val (warn/crit Range)` column header. The warning range is always on the
    left and the critical range on the right; a range that is not configured at
    all is shown as `-` so the remaining one stays unambiguous (LibreNMS'
    auto-discovery commonly sets only the critical limits). A range with a
    limit on only one side is left open on the other (e.g. `..70.0`). Returns
    an empty string when the sensor has no numeric limits at all (e.g. discrete
    state sensors).
    """

    def _range(low, high):
        if low is None and high is None:
            return ''
        return f'{"" if low is None else low}..{"" if high is None else high}'

    warn = _range(sensor['sensor_limit_low_warn'], sensor['sensor_limit_warn'])
    crit = _range(sensor['sensor_limit_low'], sensor['sensor_limit'])
    if not warn and not crit:
        return ''
    return f'{warn or "-"}/{crit or "-"}'


def main():
    """The main function. This is where the magic happens."""

    # parse the command line
    try:
        args = parse_args()
    except SystemExit:
        sys.exit(STATE_UNKNOWN)

    # fetch data
    if args.TEST is None:
        mysql_connection = {
            'defaults_file': args.DEFAULTS_FILE,
            'defaults_group': args.DEFAULTS_GROUP,
            'timeout': args.TIMEOUT,
        }
        conn = lib.base.coe(lib.db_mysql.connect(mysql_connection))
        lib.base.coe(lib.db_mysql.check_privileges(conn))
        sql = """
            SELECT
                d.hostname,
                d.sysName,
                d.sysDescr,
                d.type,
                l.location,
                s.sensor_id,
                s.sensor_descr,
                s.sensor_current,
                s.sensor_limit,
                s.sensor_limit_warn,
                s.sensor_limit_low,
                s.sensor_limit_low_warn,
                s.lastupdate,
                s.sensor_class,
                st.state_descr,
                st.state_generic_value
            FROM
                devices AS d
                    LEFT JOIN
                locations AS l ON d.location_id = l.id
                    LEFT JOIN
                device_group_device AS dgd ON d.device_id = dgd.device_id
                    LEFT JOIN
                device_groups AS dg ON dgd.device_group_id = dg.id
                    LEFT JOIN
                sensors AS s ON d.device_id = s.device_id
                    LEFT JOIN
                sensors_to_state_indexes AS stsi ON s.sensor_id = stsi.sensor_id
                    LEFT JOIN
                state_translations AS st ON stsi.state_index_id = st.state_index_id
                    AND st.state_value = s.sensor_current
            WHERE
                disable_notify = 0
                AND s.sensor_alert = 1
        """
        data = []
        if args.DEVICE_GROUP:
            sql += ' AND dg.name LIKE %s '
            data.append(args.DEVICE_GROUP)
        if args.DEVICE_HOSTNAME:
            sql += (
                f' AND d.hostname IN ({", ".join("%s" for _ in args.DEVICE_HOSTNAME)}) '
            )
            data += args.DEVICE_HOSTNAME
        if args.DEVICE_TYPE:
            sql += f' AND d.type IN ({", ".join("%s" for _ in args.DEVICE_TYPE)}) '
            data += args.DEVICE_TYPE
        sql += ' ORDER BY d.hostname, s.sensor_descr'
        sensors = lib.base.coe(lib.db_mysql.select(conn, sql, data))
        lib.db_mysql.close(conn)
    else:
        # do not query the database, put in test data (a JSON array of rows,
        # shaped exactly like the SELECT above returns them)
        stdout, _, _ = lib.lftest.test(args.TEST)
        sensors = json.loads(stdout)

    # Before anything is counted: the same sensor can arrive several times, and every
    # copy would otherwise be counted, graphed and printed as one more sensor.
    sensors = deduplicate(sensors)
    # Worst first, then by device and sensor name so the order is stable between runs
    # and two hosts stay comparable. Applied before the display strings are built, so
    # the sort sees the readings rather than the text they turn into.
    sensors.sort(
        key=lambda item: (
            severity_rank(item),
            item['hostname'] or '',
            item['sensor_descr'] or '',
        )
    )

    # init some vars
    state = STATE_OK
    perfdata = ''
    alert_count = 0
    sensors_count = len(sensors)

    # analyze data
    for i, sensor in enumerate(sensors):
        if not sensor['sysName']:
            sensors[i]['sysName'] = sensor['sysDescr']

        # determine the state from the raw values before the display strings
        # below overwrite sensor_current
        local_state = get_sensor_state(sensor)
        if local_state != STATE_OK:
            alert_count += 1
        sensors[i]['state'] = lib.base.state2str(
            local_state,
            empty_ok=False,
        )
        state = lib.base.get_worst(local_state, state)

        # turn raw values into human-readable display strings
        if sensor['sensor_class'] == 'state':
            sensors[i]['sensor_current'] = sensor['state_descr']
        sensor_range = format_sensor_range(sensor)
        if sensor_range:
            sensor['sensor_current'] = f'{sensor["sensor_current"]} ({sensor_range})'
        if sensor['lastupdate']:
            delta = lib.time.now(as_type='datetime') - sensor['lastupdate']
            sensor['lastupdate'] = lib.human.seconds2human(delta.total_seconds())

    # filter data if compact layout is choosen (just get everything that is not ok)
    if not args.LENGTHY:
        # brief data
        sensors = [sensor for sensor in sensors if sensor['state'] != '[OK]']

    # build the message
    if state == STATE_OK:
        msg = 'Everything is ok. '
    else:
        msg = (
            f'There {lib.txt.pluralize("", alert_count, "is,are")} '
            f'{alert_count} {lib.txt.pluralize("alert", alert_count)}. '
        )
    msg += f'Checked {sensors_count} {lib.txt.pluralize("sensor", sensors_count)}.'
    msg += '\n\n'

    perfdata += lib.base.get_perfdata(
        'sensor_count',
        sensors_count,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'alert_count',
        alert_count,
        _min=0,
    )

    if sensors_count > 0:
        if not args.LENGTHY:
            msg += lib.base.get_table(
                sensors,
                [
                    'hostname',
                    'sysName',
                    'sensor_descr',
                    'sensor_current',
                    'state',
                ],
                header=[
                    'Hostname',
                    'SysName',
                    'Sensor',
                    'Val (warn/crit Range)',
                    'State',
                ],
            )
        else:
            msg += lib.base.get_table(
                sensors,
                [
                    'hostname',
                    'sysName',
                    'type',
                    'location',
                    'sensor_descr',
                    'sensor_class',
                    'lastupdate',
                    'sensor_current',
                    'state',
                ],
                header=[
                    'Hostname',
                    'SysName',
                    'Type',
                    'Location',
                    'Sensor',
                    'Class',
                    'Changed',
                    'Val (warn/crit Range)',
                    'State',
                ],
            )

    # over and out
    lib.base.oao(
        msg, state, perfdata, always_ok=args.ALWAYS_OK, no_perfdata=args.NO_PERFDATA
    )


if __name__ == '__main__':
    try:
        main()
    except Exception:
        lib.base.cu()
