#!/usr/bin/env python3
# -*- 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
from collections import Counter

import lib.args
import lib.base
import lib.human
import lib.lftest
import lib.version
from lib.globals import STATE_OK, STATE_UNKNOWN

try:
    import psutil
except ImportError:
    print('Python module "psutil" is not installed.')
    sys.exit(STATE_UNKNOWN)


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

DESCRIPTION = """Checks the system-wide file descriptor usage as a percentage of the kernel maximum.
Also lists the top processes consuming the most file descriptors to help identify
the source of high usage.
Alerts when usage exceeds the configured thresholds."""

DEFAULT_CRIT = 95  # %
DEFAULT_TOP = 5
DEFAULT_WARN = 90  # %


def parse_args():
    """Parse command line arguments using argparse."""
    parser = argparse.ArgumentParser(description=DESCRIPTION)

    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 file descriptor usage in percent. '
        'Default: %(default)s',
        dest='CRIT',
        type=int,
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--top',
        help='Number of top processes to list by open file descriptors. '
        'Default: %(default)s',
        dest='TOP',
        type=int,
        default=DEFAULT_TOP,
    )

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

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for file descriptor usage in percent. '
        'Default: %(default)s',
        dest='WARN',
        type=int,
        default=DEFAULT_WARN,
    )

    args, _ = parser.parse_known_args()
    return args


def _load_file_descriptors_fixture(raw_json):
    """Convert a test fixture into the list of floats the plugin
    expects from parsing `/proc/sys/fs/file-nr`. The kernel file has
    the form "<allocated>\\t<unused>\\t<max>\\n"; our fixture JSON
    supplies the three numbers directly:

        {"allocated": <int>, "unused": <int>, "max": <int>}
    """
    data = json.loads(raw_json)
    return [
        float(data['allocated']),
        float(data.get('unused', 0)),
        float(data['max']),
    ]


def top(count):
    """Get top X processes opening file descriptors."""
    cnt = Counter()
    msg = f'\nTop {count} processes opening file descriptors:\n'
    if lib.version.version(psutil.__version__) >= lib.version.version('5.3.0'):
        try:
            for p in psutil.process_iter(attrs=['name', 'num_fds']):
                if p.info['num_fds']:
                    cnt[p.info['name']] += p.info['num_fds']
        except psutil.NoSuchProcess:
            pass
    else:
        try:
            for p in [
                x.as_dict(attrs=['name', 'num_fds']) for x in psutil.process_iter()
            ]:
                if p['num_fds']:
                    cnt[p['name']] += p['num_fds']
        except psutil.NoSuchProcess:
            pass

    for i, p in enumerate(cnt.most_common(count)):
        msg += f'{i + 1}. {p[0]}: {p[1]} FD\n'
    return msg


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)

    # init some vars
    msg = ''
    perfdata = ''
    state = STATE_OK

    # fetch data
    if args.TEST is None:
        with open('/proc/sys/fs/file-nr') as file:
            fs = [float(item) for item in file.readline().split('\t')]
    else:
        stdout, _, _ = lib.lftest.test(args.TEST)
        fs = _load_file_descriptors_fixture(stdout)
        # force --top=0 under --test so we don't walk real processes
        args.TOP = 0

    files_allocated = fs[0]  # The number of allocated file handles
    files_max = fs[2]  # The number of system-wide maximum number of file handles
    # fs[1] is the number of unused-but-allocated file handles
    files_percent = round(files_allocated / files_max * 100, 1)

    # build the message
    msg = (
        f'{files_percent}% file descriptors used'
        f' ({lib.human.number2human(files_allocated)}'
        f'/{lib.human.number2human(files_max)})\n'
    )
    perfdata += lib.base.get_perfdata(
        'fd',
        files_percent,
        uom='%',
        warn=args.WARN,
        crit=args.CRIT,
        _min=0,
        _max=100,
    )
    state = lib.base.get_state(files_percent, args.WARN, args.CRIT, 'ge')

    # Top X processes opening file descriptors
    msg += top(args.TOP)

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


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