#!/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 sys
from pathlib import Path

import lib.args
import lib.base
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__ = '2026041001'

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.
Requires root or sudo."""


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)

    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 age in seconds. Default: >= %(default)s (365d).',
        dest='CRIT',
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--critical-count',
        help='CRIT threshold for the number of files exceeding 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(
        '--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 file age in seconds. Default: >= %(default)s (30d).',
        dest='WARN',
        default=DEFAULT_WARN,
    )

    parser.add_argument(
        '--warning-count',
        help='WARN threshold for the number of files exceeding 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.')

    # init some vars
    file_msg = ''
    file_count = 0
    crit_count = 0
    warn_count = 0
    ages = []

    if args.FILENAME:
        path = Path(args.FILENAME)
        for item in sorted(Path(path.anchor).glob(str(path.relative_to(path.anchor)))):
            try:
                if item.is_file() and args.ONLY_DIRS:
                    continue
                if item.is_dir() and args.ONLY_FILES:
                    continue

                # brandnew files might get negative values
                age = abs(lib.time.now() - item.stat().st_mtime)
            except FileNotFoundError:
                # it is normal that files disappear while reading
                continue
            ages.append(age)
            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(age, args.WARN)):
                item_state = STATE_WARN
                warn_count += 1
            if not lib.base.coe(lib.base.match_range(age, args.CRIT)):
                item_state = STATE_CRIT
                crit_count += 1

            file_count += 1
            if item_state != STATE_OK:
                file_msg += (
                    f'* {item}:'
                    f' {lib.human.seconds2human(age)}'
                    f'{lib.base.state2str(item_state, prefix=" ")}'
                    f'\n'
                )

    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

                # brandnew files might get negative values
                age = abs(lib.time.now() - item.stat().st_mtime)
                ages.append(age)
                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(age, args.WARN)):
                    item_state = STATE_WARN
                    warn_count += 1
                if not lib.base.coe(lib.base.match_range(age, args.CRIT)):
                    item_state = STATE_CRIT
                    crit_count += 1

                file_count += 1
                if item_state != STATE_OK:
                    file_msg += (
                        f'* {item.name}:'
                        f' {lib.human.seconds2human(age)}'
                        f'{lib.base.state2str(item_state, prefix=" ")}'
                        f'\n'
                    )
            except smbprotocol.exceptions.SMBOSError:
                # it is normal that files disappear while reading
                pass

    # build the message
    if not lib.base.coe(lib.base.match_range(crit_count, args.CRIT_COUNT)):
        state = STATE_CRIT
    elif not lib.base.coe(lib.base.match_range(warn_count, args.WARN_COUNT)):
        state = STATE_WARN
    else:
        state = STATE_OK

    if file_msg.count('\n') + 1 > 10:
        # shorten the message
        file_msg = file_msg.split('\n')
        file_msg = [*file_msg[0:5], '* ...', *file_msg[-5:]]
        file_msg = '\n'.join(file_msg)

    if state == STATE_OK:
        if warn_count == 0 and crit_count == 0:
            msg = (
                f'Everything is ok. {file_count}'
                f' {lib.txt.pluralize("item", file_count)}'
                f' checked, all within the specified'
                f' count and time range.\n\n'
            )
        else:
            warn_hr = (
                lib.human.seconds2human(args.WARN)
                if isinstance(lib.base.guess_type(args.WARN), int)
                else args.WARN
            )
            crit_hr = (
                lib.human.seconds2human(args.CRIT)
                if isinstance(lib.base.guess_type(args.CRIT), int)
                else args.CRIT
            )
            msg = (
                f'Everything is ok. {file_count}'
                f' {lib.txt.pluralize("item", file_count)}'
                f' checked. All within the specified'
                f' count range, but {warn_count}'
                f' outside "{warn_hr}" time range,'
                f' and {crit_count}'
                f' outside "{crit_hr}" time range.'
                f'\n\n'
            ) + file_msg
    else:
        warn_hr = (
            lib.human.seconds2human(args.WARN)
            if isinstance(lib.base.guess_type(args.WARN), int)
            else args.WARN
        )
        crit_hr = (
            lib.human.seconds2human(args.CRIT)
            if isinstance(lib.base.guess_type(args.CRIT), int)
            else args.CRIT
        )
        msg = (
            f'{warn_count}'
            f' {lib.txt.pluralize("item", warn_count)}'
            f' outside count range "{args.WARN_COUNT}"'
            f' and outside "{warn_hr}" time range.'
            f' {crit_count}'
            f' {lib.txt.pluralize("item", crit_count)}'
            f' outside count range "{args.CRIT_COUNT}"'
            f' and outside "{crit_hr}" time range.'
            f' {file_count}'
            f' {lib.txt.pluralize("item", file_count)}'
            f' checked. \n\n'
        ) + file_msg

    # calc perfdata if requested
    perfdata = ''
    if args.PERFDATA_MODE == 'mean' and len(ages) > 0:
        perfdata += lib.base.get_perfdata(
            label='mean-ages', value=round(float(sum(ages) / len(ages)), 3), uom='s'
        )
    elif args.PERFDATA_MODE == 'median' and len(ages) > 0:
        ages_sorted = sorted(ages)
        middle_index = int(len(ages_sorted) / 2)
        # differentiate even / uneven list
        if len(ages_sorted) % 2 == 0:
            # even
            median = float(
                (ages_sorted[middle_index] + ages_sorted[middle_index - 1]) / 2
            )
        else:
            # uneven (manually "flooring" it up)
            median = float(ages_sorted[int(middle_index + 0.5)])
        perfdata += lib.base.get_perfdata(
            label='median-ages', value=round(median, 3), uom='s'
        )

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


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