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

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

missing_lib = None
try:
    import lib.smb

    HAVE_SMB = True
except ModuleNotFoundError as e:
    HAVE_SMB = False
    missing_lib = e.name

missing_smb_lib = False
try:
    import smbprotocol.exceptions
except ImportError:
    missing_smb_lib = 'smbclient'


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

DESCRIPTION = """Checks the time since last modification of one or more files or directories. Supports
glob patterns (including recursive), SMB shares, and optional aggregation (mean or
median) across all matched files. Can also alert on the number of files within a
specific age range.
Supports extended reporting via --lengthy.
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 = 60 * 60 * 24 * 365  # sec
DEFAULT_WARN = 60 * 60 * 24 * 30  # sec
DEFAULT_CRIT_COUNT = 0
DEFAULT_WARN_COUNT = 0
DEFAULT_PATTERN = '*'
DEFAULT_TIMEOUT = 3
DEFAULT_PERFDATA_MODE = None


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 age in seconds. '
        'Supports Nagios ranges. '
        'Example: `20:` alerts if a file is younger than 20 seconds. '
        'Default: %(default)s (365 days)',
        dest='CRIT',
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--critical-count',
        help='CRIT threshold for the number of files outside the critical age. '
        'Supports Nagios ranges. '
        'Example: `2:` alerts if fewer than 2 files are outside the critical age. '
        'Default: %(default)s',
        dest='CRIT_COUNT',
        default=DEFAULT_CRIT_COUNT,
    )

    parser.add_argument(
        '--filename',
        help='File or directory name to check (supports glob patterns). '
        'Beware of recursive globs. '
        'Mutually exclusive with --url.',
        dest='FILENAME',
    )

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

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

    parser.add_argument(
        '--only-dirs',
        help='Only consider directories, ignoring files.',
        dest='ONLY_DIRS',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--only-files',
        help='Only consider files, ignoring directories.',
        dest='ONLY_FILES',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--password',
        help='Password for SMB authentication.',
        dest='PASSWORD',
    )

    parser.add_argument(
        '--pattern',
        help='SMB search pattern to match directory or file names. '
        'Use `*` for multiple characters and `?` for a single character. '
        'Does not support regex. '
        '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',
        dest='TIMEOUT',
        type=int,
        default=DEFAULT_TIMEOUT,
    )

    parser.add_argument(
        '-u',
        '--url',
        help='SMB URL of the file or directory to check, starting with `smb://`. '
        'Mutually exclusive with --filename.',
        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 age in seconds. '
        'Supports Nagios ranges. '
        'Example: `15:` alerts if a file is younger than 15 seconds. '
        'Default: %(default)s (30 days)',
        dest='WARN',
        default=DEFAULT_WARN,
    )

    parser.add_argument(
        '--warning-count',
        help='WARN threshold for the number of files outside the warning age. '
        'Supports Nagios ranges. '
        'Example: `3:` alerts if fewer than 3 files are outside the warning age. '
        'Default: %(default)s',
        dest='WARN_COUNT',
        default=DEFAULT_WARN_COUNT,
    )

    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 parameter are mutually exclusive. Please only use one.'
        )
    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':
            lib.base.cu(f'The protocol "{proto}" is not supported.')

    # fetch data
    items = []

    if args.FILENAME:
        for item in lib.disk.glob(args.FILENAME):
            st = lib.disk.stat(item)
            if st is None:
                # it is normal that files disappear while reading
                continue
            if stat.S_ISREG(st.st_mode) and args.ONLY_DIRS:
                continue
            if stat.S_ISDIR(st.st_mode) and args.ONLY_FILES:
                continue
            items.append({'name': str(item), 'mtime': st.st_mtime})

    if args.URL:
        if not HAVE_SMB:
            lib.base.cu(f'Python module "{missing_lib}" is not installed.')
        for item in lib.base.coe(
            lib.smb.glob(
                url, args.USERNAME, args.PASSWORD, args.TIMEOUT, pattern=args.PATTERN
            )
        ):
            try:
                if item.is_file() and args.ONLY_DIRS:
                    continue
                if item.is_dir() and args.ONLY_FILES:
                    continue
                items.append({'name': item.name, 'mtime': item.stat().st_mtime})
            except smbprotocol.exceptions.SMBOSError:
                # it is normal that files disappear while reading
                pass

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

    # analyze data
    for item in items:
        # brandnew files might get negative values
        item['age'] = abs(lib.time.now() - item['mtime'])
        item['state'] = STATE_OK
        # not using elif, as the item could contribute to both the warn_count and crit_count
        if not lib.base.coe(lib.base.match_range(item['age'], args.WARN)):
            item['state'] = STATE_WARN
            warn_count += 1
        if not lib.base.coe(lib.base.match_range(item['age'], args.CRIT)):
            item['state'] = STATE_CRIT
            crit_count += 1

    # The age thresholds decide how many items are too old, the count thresholds decide
    # what that number means for the check. Both counts are compared, so the message
    # below can say which of the two broke.
    warn_breached = not lib.base.coe(lib.base.match_range(warn_count, args.WARN_COUNT))
    crit_breached = not lib.base.coe(lib.base.match_range(crit_count, args.CRIT_COUNT))
    if warn_breached:
        state = STATE_WARN
    if crit_breached:
        state = STATE_CRIT

    # build the message
    # One clause per severity, naming how many items broke its age range. The count
    # range is named wherever the admin set one, because it is then the count that
    # decides the state, and a bare "1 not in (0s..15s)" would read as "one item too
    # old" where the check really means "too few fresh ones". The marker sits on the
    # clause whose count broke its threshold.
    severities = (
        {
            'age': args.WARN,
            'breached': warn_breached,
            'count': warn_count,
            'count_default': DEFAULT_WARN_COUNT,
            'count_spec': args.WARN_COUNT,
            'name': 'warning',
            'state': STATE_WARN,
        },
        {
            'age': args.CRIT,
            'breached': crit_breached,
            'count': crit_count,
            'count_default': DEFAULT_CRIT_COUNT,
            'count_spec': args.CRIT_COUNT,
            'name': 'critical',
            'state': STATE_CRIT,
        },
    )

    clauses = []
    for severity in severities:
        if not severity['count'] and not severity['breached']:
            continue
        clause = f'{severity["count"]} ' + lib.base.coe(
            lib.base.range2txt(severity['age'], fmt=lib.human.seconds2human)
        )
        if str(severity['count_spec']) != str(severity['count_default']):
            clause += ', ' + lib.base.coe(
                lib.base.range2txt(
                    severity['count_spec'],
                    value=severity['count'],
                    value_name=f'{severity["name"]} count',
                    view='alert' if severity['breached'] else 'ok',
                )
            )
        if severity['breached']:
            clause += lib.base.state2str(severity['state'], prefix=' ')
        clauses.append(clause)

    head = f'Age of {len(items)} {lib.txt.pluralize("item", len(items))} checked'
    if clauses:
        prefix = 'Everything is ok. ' if state == STATE_OK else ''
        msg = f'{prefix}{head}. ' + ', '.join(clauses) + '.'
    else:
        condition = lib.base.coe(
            lib.base.range2txt(args.WARN, fmt=lib.human.seconds2human, view='ok')
        )
        msg = f'Everything is ok. {head}, all {condition}.'

    ages = [item['age'] for item in items]
    if args.PERFDATA_MODE == 'mean' and ages:
        perfdata += lib.base.get_perfdata(
            label='mean-ages', value=round(statistics.mean(ages), 3), uom='s'
        )
    elif args.PERFDATA_MODE == 'median' and ages:
        perfdata += lib.base.get_perfdata(
            label='median-ages', value=round(statistics.median(ages), 3), uom='s'
        )

    # build table output
    # --brief hides the rows within the thresholds; it is a display filter only, every
    # item was checked above and still drives the state and the perfdata.
    rows = [item for item in items if item['state']] if args.BRIEF else items
    for item in rows:
        item['age_hr'] = lib.human.seconds2human(item['age'])
        item['state_hr'] = lib.base.state2str(item['state'], empty_ok=False)
        item['mtime_hr'] = lib.time.epoch2iso(item['mtime'])
    if rows:
        keys = ['name', 'age_hr']
        header = ['File', 'Age']
        if args.LENGTHY:
            keys.append('mtime_hr')
            header.append('Last Modified')
        # the state belongs in the last column, otherwise a web interface
        # replacing `[WARNING]` with an icon breaks the monospace table
        keys.append('state_hr')
        header.append('State')
        msg += '\n\n' + lib.base.get_table(rows, keys, header=header)

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