#!/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 re
import sys

import lib.args
import lib.base
import lib.db_sqlite
import lib.disk
import lib.lftest
import lib.txt
from lib.globals import STATE_OK, STATE_UNKNOWN

# psutil is only needed on Windows, where /proc/net/dev does not exist.
if lib.base.WINDOWS:
    try:
        import psutil
    except ImportError:
        print('Python module "psutil" is not installed.')
        sys.exit(STATE_UNKNOWN)


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

DESCRIPTION = """Monitors network interface errors per interface and alerts on receive and transmit
errors. On Linux it additionally shows the receive and transmit error breakdown (the
FIFO, frame and carrier error groups); on Windows only the receive and transmit error
totals are available. Each counter is reported as a per-second rate measured between
two check runs, so the values reflect the current situation rather than totals
accumulated since boot. Alerts when the combined error rate (receive plus transmit
errors) of an interface leaves the warning or critical range (default: warns on any
new errors)."""

# /proc/net/dev column layout (0-based, after the "iface:" prefix):
#   rx: bytes packets errs drop fifo frame compressed multicast
#   tx: bytes packets errs drop fifo colls carrier compressed
# Only error counters are monitored. Per the kernel statistics documentation, drops
# and collisions are not errors, so they are not collected. The "frame" and "carrier"
# columns are themselves aggregates (frame = rx length/overrun/CRC/frame-alignment;
# carrier = tx carrier/aborted/window/heartbeat). The alert sums only the "errs" totals
# (rx_errors + tx_errors); fifo/frame/carrier are shown as a breakdown and never added
# on top. So even where the kernel does not guarantee that a breakdown counter is part
# of "errs" (rx_fifo is driver-dependent), the alert cannot be inflated.
NETDEV_COLUMN = {
    'rx_errs': 2,
    'rx_fifo': 4,
    'rx_frame': 5,
    'tx_carrier': 14,
    'tx_errs': 10,
    'tx_fifo': 12,
}

# Metrics collected per platform. Windows (psutil) only exposes the error totals.
LINUX_METRICS = sorted(NETDEV_COLUMN.keys())
WINDOWS_METRICS = ['rx_errs', 'tx_errs']
METRICS = WINDOWS_METRICS if lib.base.WINDOWS else LINUX_METRICS

# Metrics summed into the per-interface alert value. Only the kernel error totals,
# to avoid double-counting the fifo/frame/carrier breakdown they already contain.
ALERT_METRICS = ['rx_errs', 'tx_errs']

DEFAULT_CRIT = None
DEFAULT_NO_MATCH_SEVERITY = 'ok'
DEFAULT_WARN = '0'


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(
        '-c',
        '--critical',
        help='CRIT threshold for the combined per-second error rate of an interface. '
        'Supports Nagios ranges. '
        'Default: no critical threshold',
        dest='CRIT',
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--ignore',
        help='Ignore items whose name matches this Python regular expression. '
        'Case-sensitive by default; use `(?i)` for case-insensitive matching. '
        'Can be specified multiple times.',
        dest='IGNORE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--match',
        help='Only check items whose name matches this Python regular expression. '
        'Case-sensitive by default; use `(?i)` for case-insensitive matching. '
        'Can be specified multiple times. ' + lib.args.MATCH_IGNORE_PRECEDENCE,
        dest='MATCH',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--no-match-severity',
        help=lib.args.help('--no-match-severity') + ' Default: %(default)s',
        dest='NO_MATCH_SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_NO_MATCH_SEVERITY,
    )

    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(
        '-w',
        '--warning',
        help='WARN threshold for the combined per-second error rate of an interface. '
        'Supports Nagios ranges. '
        'Default: %(default)s',
        dest='WARN',
        default=DEFAULT_WARN,
    )

    args, _ = parser.parse_known_args()
    return args


def parse_netdev(stdout):
    """Parse the contents of /proc/net/dev into a per-interface counter dict.

    Returns a dict mapping interface name to a dict of the monitored error
    counters, for example `{'eth0': {'rx_errs': 0, 'tx_carrier': 2, ...}}`.
    """
    interfaces = {}
    for line in stdout.splitlines():
        if ':' not in line:
            # header lines ("Inter-|   Receive ...") carry no colon-separated iface
            continue
        name, _, rest = line.partition(':')
        name = name.strip()
        fields = rest.split()
        if len(fields) < 16:
            continue
        interfaces[name] = {
            metric: int(fields[NETDEV_COLUMN[metric]]) for metric in LINUX_METRICS
        }
    return interfaces


def get_netdev():
    """Collect the monitored error counters per interface.

    Returns the (success, result) tuple used throughout the libraries.
    """
    if lib.base.WINDOWS:
        try:
            counters = psutil.net_io_counters(pernic=True, nowrap=True)
        except Exception as e:
            return (False, f'psutil raised an error: {e}')
        interfaces = {}
        for name, values in counters.items():
            interfaces[name] = {
                'rx_errs': getattr(values, 'errin', 0),
                'tx_errs': getattr(values, 'errout', 0),
            }
        return (True, interfaces)

    success, stdout = lib.disk.read_file('/proc/net/dev')
    if not success:
        return (False, stdout)
    return (True, parse_netdev(stdout))


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)

    # set default values for append parameters that were not specified
    if args.IGNORE is None:
        args.IGNORE = []
    if args.MATCH is None:
        args.MATCH = []

    # compile the interface filter regexes once
    ignore_patterns = [
        lib.base.coe(p) for p in lib.txt.compile_regex(args.IGNORE, '--ignore')
    ]
    match_patterns = [
        lib.base.coe(p) for p in lib.txt.compile_regex(args.MATCH, '--match')
    ]

    # fetch data
    if args.TEST is None:
        interfaces = lib.base.coe(get_netdev())
    else:
        # do not read /proc, put in test data (Linux /proc/net/dev format)
        stdout, _, _ = lib.lftest.test(args.TEST)
        interfaces = parse_netdev(stdout)

    # --match/--ignore: filter interfaces by name
    filtered = {}
    for name, counters in interfaces.items():
        if not name:
            continue
        if ignore_patterns and any(p.search(name) for p in ignore_patterns):
            continue
        if match_patterns and not any(p.search(name) for p in match_patterns):
            continue
        filtered[name] = counters
    interfaces = filtered

    # init some vars
    msg = ''
    state = STATE_OK
    perfdata = ''
    table_data = []
    analyzed = []

    # Turn the cumulative kernel counters into per-second rates using the
    # previous run as a baseline. One cache file per interface keeps each
    # interface's two-sample history independent. In test mode, skip persistent
    # state and treat the fetched counters as the per-interval delta directly,
    # so fixtures can drive the full state machine.
    rates = {}
    if args.TEST is None:
        for name, counters in interfaces.items():
            safe_name = re.sub(r'\W+', '_', name)
            iface_rates = lib.db_sqlite.per_second_deltas(
                f'linuxfabrik-monitoring-plugins-network-errors-{safe_name}.db',
                name,
                counters,
            )
            if iface_rates is not None:
                rates[name] = iface_rates
        if interfaces and not rates:
            # no usable baseline yet (first run, or counters just reset)
            lib.base.oao('Waiting for more data.', STATE_OK)
    else:
        for name, counters in interfaces.items():
            rates[name] = {metric: float(counters[metric]) for metric in METRICS}

    # analyze data
    for name in sorted(rates):
        iface_rates = rates[name]
        # alert only on the kernel error totals; fifo/frame/carrier are already
        # included in them and must not be summed again
        total_rate = sum(iface_rates[metric] for metric in ALERT_METRICS)
        iface_state = lib.base.get_state(
            total_rate, args.WARN, args.CRIT, _operator='range'
        )
        state = lib.base.get_worst(state, iface_state)

        safe_name = re.sub(r'\W+', '_', name)
        for metric in METRICS:
            perfdata += lib.base.get_perfdata(
                f'{safe_name}_{metric}_per_second',
                round(iface_rates[metric], 4),
                _min=0,
            )
        perfdata += lib.base.get_perfdata(
            f'{safe_name}_errors_per_second',
            round(total_rate, 4),
            warn=args.WARN,
            crit=args.CRIT,
            _min=0,
        )

        analyzed.append({'name': name, 'state': iface_state, 'rate': total_rate})

        row = {'name': name}
        row.update({metric: round(iface_rates[metric], 4) for metric in METRICS})
        row['errors'] = (
            f'{round(total_rate, 4)}{lib.base.state2str(iface_state, prefix=" ")}'
        )
        table_data.append(row)

    # build the message
    if state == STATE_OK:
        msg = 'Everything is ok.'
    else:
        # header shows the interface in the worst state with the highest rate
        worst = max(
            (a for a in analyzed if a['state'] == state),
            key=lambda a: a['rate'],
        )
        msg = (
            f'{worst["name"]}: {round(worst["rate"], 4)} errors/s'
            f'{lib.base.state2str(worst["state"], prefix=" ")}'
        )

    keys = ['name', *METRICS, 'errors']
    headers = [
        'Interface',
        *[m.replace('_', ' ').title() + '/s' for m in METRICS],
        'Errors/s',
    ]

    if table_data:
        msg += '\n\n' + lib.base.get_table(table_data, keys, header=headers)
    else:
        msg = 'Nothing checked.'
        state = lib.base.str2state(args.NO_MATCH_SEVERITY)

    # 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()
