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

import lib.args
import lib.base
import lib.human
import lib.lftest
import lib.restic
import lib.shell
import lib.time
import lib.txt
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN

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

DESCRIPTION = """Checks the age of the newest snapshot in a restic repository. Alerts when the most
recent backup is older than the configured thresholds. Useful for detecting failed or
missing backup runs.
Supports extended reporting via --lengthy. Requires root or sudo."""

DEFAULT_CRIT = None
DEFAULT_GROUP_BY = 'host,paths'
DEFAULT_LATEST = 3  # count
DEFAULT_LENGTHY = False
DEFAULT_TIMEOUT = 280  # the command timeout of the basket is 300
DEFAULT_WARN = 24  # hours


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 age of the newest snapshot in each group, in hours. '
        'Default: %(default)s',
        dest='CRIT',
        type=int,
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--group-by',
        help='Comma-separated list of fields to group snapshots by. '
        'Allowed values: host, paths, tags. '
        'Default: %(default)s',
        dest='GROUP_BY',
        default=DEFAULT_GROUP_BY,
    )

    parser.add_argument(
        '--host',
        help='Only consider snapshots for this host. Can be specified multiple times.',
        dest='HOST',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--latest',
        help='Number of latest snapshots to show per host and path. '
        'Default: %(default)s',
        dest='LATEST',
        type=int,
        default=DEFAULT_LATEST,
    )

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

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

    parser.add_argument(
        '--password-file',
        help='Path to the file containing the repository password. '
        'Must be a file that only root can change.',
        dest='PASSWORD_FILE',
    )

    parser.add_argument(
        '--path',
        help='Only consider snapshots for this path. Can be specified multiple times.',
        dest='PATH',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--repo',
        help='Restic repository location. '
        'A local repository has to be a directory that only root can change. '
        'Give exactly one of --repo and --repository-file.',
        dest='REPO',
        default=None,
    )

    parser.add_argument(
        '--repository-file',
        help='Path to a file whose first line is the repository location. '
        'Use this instead of --repo for a repository whose location selects an '
        'external helper (an `rclone:` repository): the file must be one that only '
        'root can change, so an unprivileged caller cannot choose what runs. '
        'Give exactly one of --repo and --repository-file.',
        dest='REPOSITORY_FILE',
    )

    parser.add_argument(
        '--tag',
        help='Only consider snapshots matching this taglist in the format `tag[,tag,...]`. '
        'Can be specified multiple times.',
        dest='TAG',
        action='append',
        default=None,
    )

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

    parser.add_argument(
        '--timeout',
        help=lib.args.help('--timeout') + ' Default: %(default)s (seconds)',
        dest='TIMEOUT',
        type=int,
        default=DEFAULT_TIMEOUT,
    )

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for the age of the newest snapshot in each group, in hours. '
        'Default: %(default)s',
        dest='WARN',
        type=int,
        default=DEFAULT_WARN,
    )

    args, _ = parser.parse_known_args()
    return args


def get_snapshot_data(snapshot, args):
    """Enrich data returned from retic's snapshot command."""
    snapshot['ts'] = snapshot['time'][0:19].replace(
        'T', ' '
    )  # 2022-11-28T16:22:43.896818692Z
    snapshot['age'] = lib.time.timestrdiff(lib.time.now('iso'), snapshot['ts'])
    snapshot['age_hr'] = lib.human.seconds2human(snapshot['age'])  # human-readable
    snapshot['p'] = ' '.join(snapshot['paths'])
    snapshot['tags'] = ','.join(snapshot['tags']) if 'tags' in snapshot else ''
    snapshot['state'] = lib.base.get_state(
        int(snapshot['age'] / 3600), args.WARN, args.CRIT
    )

    return snapshot


def get_message(table_data, args):
    """Build the message. Show latest/newest snapshot on top, which is the last one in the list."""
    latest = table_data[-1]
    snap_count = len(table_data)
    msg = (
        f'\n\nLatest snapshot {latest["age_hr"]} ago'
        f'{lib.base.state2str(latest["state"], prefix=" ")}'
        f' ({latest["ts"]}@{latest["hostname"]}'
        f':{latest["p"]}, ID {latest["short_id"]});'
        f' {snap_count}'
        f' {lib.txt.pluralize("snapshot", snap_count)}'
        f' found\n\n'
    )
    table_data[-1]['age_hr'] += lib.base.state2str(table_data[-1]['state'], prefix=' ')
    if table_data:
        if not args.LENGTHY:
            keys = [
                'short_id',
                'ts',
                'age_hr',
                'hostname',
                'p',
                #'username',
            ]
            headers = [
                'Short ID',
                'Timestamp',
                'Age',
                'Host',
                'Paths',
                #'User',
            ]
        else:
            keys = [
                'short_id',
                'ts',
                'age_hr',
                'hostname',
                'p',
                'tags',
                #'username',
            ]
            headers = [
                'Short ID',
                'Timestamp',
                'Age',
                'Host',
                'Paths',
                'Tags',
                #'User',
            ]
        msg += lib.base.get_table(
            table_data,
            keys,
            header=headers,
            sort_by_key='ts',
            sort_order_reverse=True,
        )

    return msg, table_data[-1]['state']


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)

    # fetch data
    if args.TEST is None:
        cmd = [
            'restic',
            '--json',
            *lib.restic.READ_ONLY_ARGS,
            *lib.base.coe(lib.restic.repo_args(args.REPO, args.REPOSITORY_FILE)),
            *lib.base.coe(lib.restic.password_file_arg(args.PASSWORD_FILE)),
            f'--latest={args.LATEST}',
        ]
        if args.HOST:
            cmd += [f'--host={host}' for host in args.HOST]
        if args.PATH:
            cmd += [f'--path={path}' for path in args.PATH]
        if args.TAG:
            cmd += [f'--tag={tag}' for tag in args.TAG]
        if args.GROUP_BY:
            cmd.append(f'--group-by={args.GROUP_BY}')
        cmd.append('snapshots')
        success, result = lib.shell.shell_exec(cmd, timeout=args.TIMEOUT)
        if not success and result.startswith('Timeout after'):
            lib.base.oao(
                f'Timeout after {args.TIMEOUT}s while running `restic snapshots`.',
                STATE_WARN,
                always_ok=args.ALWAYS_OK,
            )
        stdout, stderr, retc = lib.base.coe((success, result))
        if retc != 0:
            lib.base.oao(
                f'`restic snapshots` failed (exit {retc}): '
                + ' '.join(lib.restic.error_lines(stderr)),
                STATE_WARN,
                always_ok=args.ALWAYS_OK,
            )
    else:
        # do not call the command, put in test data
        stdout, stderr, _retc = lib.lftest.test(args.TEST)

    try:
        snapshots = json.loads(stdout)
    except ValueError:
        lib.base.cu('No JSON object could be decoded.')

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

    # `restic snapshots --json` prints `[]` both for a repository without any
    # snapshot and for filters that drop every snapshot, with or without
    # `--group-by` (verified against the restic 0.19 source, cmd_snapshots.go).
    # Either way there is no backup to rely on, which is exactly what this check
    # is there to report, so it is a WARNING and not a plugin problem. The two
    # cases differ only in whether a filter was given.
    if not snapshots:
        filters = [
            *(f'--host={host}' for host in args.HOST or []),
            *(f'--path={path}' for path in args.PATH or []),
            *(f'--tag={tag}' for tag in args.TAG or []),
        ]
        if filters:
            msg = (
                f'No snapshot matches {" ".join(filters)}. Either no backup has run'
                ' for this selection yet, or the filters do not fit the snapshots in'
                ' the repository (compare with `restic snapshots`).'
            )
        else:
            msg = 'The repository has no snapshot at all. No backup has run yet.'
        lib.base.oao(msg, STATE_WARN, always_ok=args.ALWAYS_OK)

    # analyze data
    snapshot_count = 0
    if 'group_key' in snapshots[0]:
        # --group-by was used, so check every group of snapshots
        for snapshot in snapshots:
            table_data = []
            for s in snapshot['snapshots']:
                table_data.append(get_snapshot_data(s, args))
            snapshot_msg, snapshot_state = get_message(table_data, args)
            snapshot_count += len(table_data)
            msg += snapshot_msg
            state = lib.base.get_worst(snapshot_state, state)
    else:
        for snapshot in snapshots:
            table_data.append(get_snapshot_data(snapshot, args))
        msg, state = get_message(table_data, args)
        snapshot_count = len(table_data)

    # build the message
    perfdata += lib.base.get_perfdata(
        'snapshots',
        snapshot_count,
        uom='',
        _min=0,
    )

    if state == STATE_CRIT:
        msg = 'There are critical errors.' + msg
    elif state == STATE_WARN:
        msg = 'There are warnings.' + msg
    else:
        msg = 'Everything is ok.' + msg

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