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

import lib.args
import lib.base
import lib.disk
import lib.human
import lib.smb
import lib.txt
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN

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

DESCRIPTION = """Checks file sizes against configurable thresholds using human-readable units (e.g.
25M, 1G). Supports glob patterns, SMB shares, and optional aggregation (mean or
median) across all matched files. Directories are skipped because their reported
size is not meaningful across filesystems.
Alerts when any file exceeds the configured size thresholds.
Reads only the file metadata, never the contents. The plugin is not shipped in the
sudoers allowlist, so it can only see files the monitoring user may read; see
PLUGINS-FILE.md for what to do about a file it cannot access."""


DEFAULT_CRIT = '1G'
DEFAULT_PATTERN = '*'
DEFAULT_PERFDATA_MODE = None
DEFAULT_TIMEOUT = 3
DEFAULT_WARN = '25M'


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(
        '--brief',
        help=lib.args.help('--brief'),
        dest='BRIEF',
        action='store_true',
        default=False,
    )
    parser.add_argument(
        '-c',
        '--critical',
        help='CRIT threshold for the file size in human-readable format '
        '(base is always 1024; valid qualifiers are '
        'B, KiB, MiB, GiB etc., see UNITS.md; '
        'a value without a qualifier is a number of bytes). '
        'Supports Nagios ranges. '
        'Example: `:1G` alerts if size is greater than 1 GiB. '
        'Default: %(default)s',
        dest='CRIT',
        default=DEFAULT_CRIT,
    )
    parser.add_argument(
        '--filename',
        help='Path of the file to check. '
        'Supports glob patterns according to https://docs.python.org/3/library/glob.html. '
        'Recursive globs can cause high memory usage. '
        'Mutually exclusive with `-u` / `--url`. '
        'Example: `--filename /tmp/*.log`.',
        dest='FILENAME',
    )
    parser.add_argument(
        '--no-perfdata',
        help=lib.args.help('--no-perfdata'),
        dest='NO_PERFDATA',
        action='store_true',
        default=False,
    )
    parser.add_argument(
        '--password',
        help='Password for SMB authentication.',
        dest='PASSWORD',
    )
    parser.add_argument(
        '--pattern',
        help='Search string to match against SMB directory or file names. '
        'Use `*` as a wildcard for multiple characters and `?` for a single character. '
        'Does not support regex patterns. '
        'Default: %(default)s',
        dest='PATTERN',
        default=DEFAULT_PATTERN,
    )
    parser.add_argument(
        '--perfdata-mode',
        help='Aggregation mode for performance data across matched files. '
        'Default: %(default)s',
        dest='PERFDATA_MODE',
        default=DEFAULT_PERFDATA_MODE,
        choices=[
            'mean',
            'median',
            'None',
        ],
    )
    parser.add_argument(
        '--timeout',
        help=lib.args.help('--timeout') + ' Default: %(default)s (seconds)',
        dest='TIMEOUT',
        type=int,
        default=DEFAULT_TIMEOUT,
    )
    parser.add_argument(
        '-u',
        '--url',
        help='URL of the file to check, starting with `smb://`. '
        'Mutually exclusive with `--filename`. '
        'Example: `--url smb://server/share/path`.',
        dest='URL',
        type=str,
    )
    parser.add_argument(
        '--username',
        help='Username for SMB authentication.',
        dest='USERNAME',
    )
    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for the file size in human-readable format '
        '(base is always 1024; valid qualifiers are '
        'B, KiB, MiB, GiB etc., see UNITS.md; '
        'a value without a qualifier is a number of bytes). '
        'Supports Nagios ranges. '
        'Example: `:1G` alerts if size is greater than 1 GiB. '
        'Default: %(default)s',
        dest='WARN',
        default=DEFAULT_WARN,
    )

    args, _ = parser.parse_known_args()
    return args


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)

    if args.FILENAME and args.URL:
        lib.base.cu(
            'The `--filename` and `-u` / `--url` parameters are mutually exclusive. Please use only one.'
        )

    # fetch data
    table_data = []
    # from local
    if args.FILENAME:
        for item in lib.disk.glob(args.FILENAME):
            item_stat = lib.disk.stat(item)
            # ignoring directories as the size of a directory is not consistently defined across
            # filesystems, and never is the size of the contents
            if item_stat is None or stat.S_ISDIR(item_stat.st_mode):
                continue
            table_data.append(
                {
                    'filename': item,
                    'size': item_stat.st_size,
                }
            )

    # or fetch data from remote
    if args.URL:
        split_url = args.URL.split('://')
        if len(split_url) != 2:
            lib.base.cu(f'Could not parse the protocol of the url "{args.URL}".')
        proto, url = split_url
        if proto == 'smb':
            for item in lib.base.coe(
                lib.smb.glob(
                    url,
                    args.USERNAME,
                    args.PASSWORD,
                    args.TIMEOUT,
                    pattern=args.PATTERN,
                )
            ):
                try:
                    # ignoring directories as the size of a directory is not
                    # consistently defined across filesystems, and never is the
                    # size of the contents
                    if item.is_dir():
                        continue
                    size = item.stat().st_size
                except OSError:
                    # it is normal that files disappear while reading
                    continue
                table_data.append(
                    {
                        # the full UNC path; str() of the entry is a repr that names
                        # the class and the base name only
                        'filename': item.path,
                        'size': size,
                    }
                )
        else:
            lib.base.cu(f'The protocol "{proto}" is not supported.')

    if len(table_data) == 0:
        msg = 'No files found.'
        lib.base.oao(msg, STATE_UNKNOWN, always_ok=args.ALWAYS_OK)

    # init some vars
    state = STATE_OK
    msg = ''
    perfdata = ''
    crit_count = 0
    warn_count = 0

    # convert human readable nagios ranges to something that the Linuxfabrik libraries
    # can understand
    CRIT = lib.human.humanrange2bytes(args.CRIT)
    WARN = lib.human.humanrange2bytes(args.WARN)

    # analyze data
    for i, item in enumerate(table_data):
        table_data[i]['size_hr'] = lib.human.bytes2human(table_data[i]['size'])
        table_data[i]['state'] = lib.base.get_state(
            item['size'],
            WARN,
            CRIT,
            _operator='range',
        )
        table_data[i]['state_hr'] = lib.base.state2str(
            table_data[i]['state'], empty_ok=False
        )
        # Counted per severity, not by the worst state of a file: a file outside the
        # critical range is usually outside the warning range too, and the message
        # says how many files each range holds, not how many the check gave up on.
        if not lib.base.coe(lib.base.match_range(item['size'], WARN)):
            warn_count += 1
        if not lib.base.coe(lib.base.match_range(item['size'], CRIT)):
            crit_count += 1
        state = lib.base.get_worst(state, table_data[i]['state'])

    # build the message
    # One clause per severity, naming how many files broke its size range, so the
    # message says what was compared instead of repeating the range syntax.
    clauses = []
    for count, spec, item_state in (
        (warn_count, WARN, STATE_WARN),
        (crit_count, CRIT, STATE_CRIT),
    ):
        if not count:
            continue
        clause = f'{count} ' + lib.base.coe(
            lib.base.range2txt(spec, fmt=lib.human.bytes2human)
        )
        clause += lib.base.state2str(item_state, prefix=' ')
        clauses.append(clause)

    head = f'Size of {len(table_data)} {lib.txt.pluralize("file", len(table_data))} checked'
    if clauses:
        msg += f'{head}. ' + ', '.join(clauses) + '.'
    else:
        condition = lib.base.coe(
            lib.base.range2txt(WARN, fmt=lib.human.bytes2human, view='ok')
        )
        msg += f'Everything is ok. {head}, all {condition}.'
    if len(table_data) == 1:
        # show info on first line when there is only one hit
        msg += (
            f' Checked {table_data[0]["filename"]}:'
            f' {table_data[0]["size_hr"]}'
            f'{lib.base.state2str(table_data[0]["state"], prefix=" ")}'
        )
    else:
        # --brief hides the rows within the thresholds. It is a display filter
        # only: every file was checked above and still drives the state, and
        # every file still emits perfdata below.
        rows = (
            [item for item in table_data if item['state']] if args.BRIEF else table_data
        )
        if rows:
            msg += '\n\n' + lib.base.get_table(
                rows,
                [
                    'filename',
                    'size_hr',
                    'state_hr',
                ],
                header=[
                    'File',
                    'Size',
                    'State',
                ],
            )

    # aggregate perfdata across all matched files if requested. A single value per
    # run avoids bloating the time series database when wildcards match many files.
    sizes = [item['size'] for item in table_data]
    if args.PERFDATA_MODE == 'mean' and len(sizes) > 0:
        perfdata += lib.base.get_perfdata(
            label='mean-sizes', value=round(statistics.mean(sizes)), uom='B'
        )
    elif args.PERFDATA_MODE == 'median' and len(sizes) > 0:
        perfdata += lib.base.get_perfdata(
            label='median-sizes', value=round(statistics.median(sizes)), uom='B'
        )

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


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