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

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

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

DESCRIPTION = """Monitors Nextcloud usage statistics via the server info API, including active user counts
over time, file shares by category, and storage metrics. Optionally lists the accounts
consuming the most storage via --top, to identify who fills up the data directory. The
listing produces no performance data. The figures are reported for trending and never
alert on their own."""

DEFAULT_INSECURE = False
DEFAULT_NO_PROXY = False
DEFAULT_TIMEOUT = 8
DEFAULT_TOP = 5
# The account listing is answered by a different endpoint than the rest of this check, and
# its cost grows with the number of accounts on the instance. Measured against Nextcloud
# 33.0.3 on MariaDB 11.8: about 2.7 ms per account once every home directory exists, but
# about 110 ms for every account whose home has never been materialised, which is what a
# freshly synced directory looks like. 1000 fresh accounts took 85 seconds that way. The
# budget therefore has to be far above the network timeout of the rest of the check, and is
# kept as its own parameter so the two never have to be traded against each other. Keep it
# below the timeout the monitoring agent grants the check.
DEFAULT_TOP_TIMEOUT = 240
DEFAULT_URL = 'http://localhost/nextcloud/ocs/v2.php/apps/serverinfo/api/v1/info'
DEFAULT_USERNAME = 'admin'


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(
        '--insecure',
        help=lib.args.help('--insecure'),
        dest='INSECURE',
        action='store_true',
        default=DEFAULT_INSECURE,
    )

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

    parser.add_argument(
        '--no-proxy',
        help=lib.args.help('--no-proxy'),
        dest='NO_PROXY',
        action='store_true',
        default=DEFAULT_NO_PROXY,
    )

    parser.add_argument(
        '--password',
        help='Password for authenticating against the Nextcloud API.',
        dest='PASSWORD',
        required=True,
    )

    parser.add_argument(
        '--proxy',
        help=lib.args.help('--proxy'),
        dest='PROXY',
        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(
        '--top',
        help='Number of top storage-consuming accounts to list. '
        'Use `--top=0` to disable. '
        'Default: %(default)s',
        dest='TOP',
        type=int,
        default=DEFAULT_TOP,
    )

    parser.add_argument(
        '--top-timeout',
        help='Network timeout in seconds for fetching the account list used by '
        '`--top`. Runs much longer than the timeout of the other requests, because '
        'the endpoint answers slower the more accounts the instance has. Keep it '
        'below the timeout the monitoring agent grants the check. '
        'Default: %(default)s (seconds)',
        dest='TOP_TIMEOUT',
        type=int,
        default=DEFAULT_TOP_TIMEOUT,
    )

    parser.add_argument(
        '--url',
        help='Nextcloud server info API URL. Default: %(default)s',
        dest='URL',
        default=DEFAULT_URL,
    )

    parser.add_argument(
        '--username',
        help='Username for authenticating against the Nextcloud API. '
        'Default: %(default)s',
        dest='USERNAME',
        default=DEFAULT_USERNAME,
    )

    args, _ = parser.parse_known_args()
    return args


def get_users_url(url):
    """Derive the account listing endpoint from the server info endpoint. Both are OCS
    routes below the same installation root, so the root is everything in front of the
    `/ocs/<version>.php/` segment. The OCS version the user pointed at is carried over
    instead of being hardcoded, so an installation reachable only via `v1.php` keeps
    working.
    """
    if '/ocs/' not in url:
        return (False, f'Cannot derive the account listing endpoint from "{url}".')
    root, rest = url.rsplit('/ocs/', 1)
    ocs_version = rest.split('/')[0]
    if not re.fullmatch(r'v\d+\.php', ocs_version):
        return (False, f'Cannot derive the account listing endpoint from "{url}".')
    return (True, f'{root}/ocs/{ocs_version}/cloud/users/details?format=json')


def get_accounts(result):
    """Pick account name and storage usage out of an account listing, sorted by usage,
    biggest first.

    Nextcloud fills the `quota` object from three different code paths, and only the
    first one carries every field: the regular one, a reduced one holding just `quota`
    and `used` for an account whose home is not reachable, and an empty one when reading
    the storage raised. An account without a usable `used` value is therefore skipped
    rather than sorted in as a zero, which would park a broken account at the bottom of
    the list and make it look empty instead of unreadable.
    """
    accounts = []
    for name, data in result.get('ocs', {}).get('data', {}).get('users', {}).items():
        quota = data.get('quota')
        if not isinstance(quota, dict):
            continue
        used = quota.get('used')
        if not isinstance(used, (int, float)) or isinstance(used, bool):
            continue
        # `total` is the limit including anything the instance counts on top of the
        # quota. It is absent on the reduced path, and Nextcloud fills it with one of the
        # negative markers of OCP\Files\FileInfo when there is no usable limit:
        # SPACE_NOT_COMPUTED (-1), SPACE_UNKNOWN (-2), SPACE_UNLIMITED (-3). None of them
        # can be related a usage to, so anything at or below zero counts as "no limit".
        total = quota.get('total')
        if not isinstance(total, (int, float)) or isinstance(total, bool) or total <= 0:
            total = None
        accounts.append((name, used, total))
    accounts.sort(key=lambda item: item[1], reverse=True)
    return accounts


def get_top_users(url, header, args):
    """Fetch the account listing and return it ready to be printed."""
    success, users_url = get_users_url(url)
    if not success:
        return (False, users_url)
    success, result = lib.url.fetch(
        users_url,
        header=header,
        insecure=args.INSECURE,
        no_proxy=args.NO_PROXY,
        proxy=args.PROXY,
        timeout=args.TOP_TIMEOUT,
    )
    if not success:
        return (False, result)
    try:
        result = json.loads(result)
    except Exception:
        return (False, 'The account listing did not return a JSON object.')
    if result.get('ocs', {}).get('meta', {}).get('status') != 'ok':
        return (False, 'The account listing did not return an "ok" state.')
    return (True, get_accounts(result))


def format_top(accounts, count):
    """Render the top storage consumers as a numbered list. An account without a quota
    limit is printed with its usage alone, because there is nothing to relate it to.
    """
    if count <= 0 or not accounts:
        return ''
    # An instance can hold fewer accounts than asked for, so the heading counts what is
    # actually listed below it instead of repeating the parameter.
    listed = min(count, len(accounts))
    lines = [f'\nTop {listed} {lib.txt.pluralize("account", listed)} by storage usage:']
    for i, (name, used, total) in enumerate(accounts[:count], start=1):
        line = f'{i}. {name}: {lib.human.bytes2human(used)}'
        if total:
            line += f' of {lib.human.bytes2human(total)} ({used / total * 100:.1f}%)'
        lines.append(line)
    return '\n'.join(lines) + '\n'


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
    accounts = []
    top_error = ''
    if args.TEST is None:
        if not args.URL.startswith('http'):
            lib.base.cu('--url parameter has to start with "http://" or https://".')
        if not args.URL.endswith('/info'):
            lib.base.cu('--url parameter has to end in "/info".')
        url = args.URL + '?format=json&skipApps=false'

        # Add the authentication and api request header
        auth = f'{args.USERNAME}:{args.PASSWORD}'
        encoded_auth = lib.txt.to_text(base64.b64encode(lib.txt.to_bytes(auth)))
        header = {
            'Authorization': f'Basic {encoded_auth}',
            'OCS-APIRequest': 'true',
        }
        # and get the info from the API
        jsonst = lib.base.coe(
            lib.url.fetch(
                url,
                header=header,
                insecure=args.INSECURE,
                no_proxy=args.NO_PROXY,
                proxy=args.PROXY,
                timeout=args.TIMEOUT,
            )
        )
        try:
            result = json.loads(jsonst)
        except Exception:
            lib.base.cu('ValueError: No JSON object could be decoded')

        if result['ocs']['meta']['status'] != 'ok':
            lib.base.cu('Sorry, something went wrong - no "ok" state returned.')

        if args.TOP > 0:
            # The account listing is a second endpoint and answers slower the more
            # accounts the instance has. It only feeds the informative listing at the
            # end of the output, so a failure here is reported in place and leaves both
            # the state and the rest of the output alone.
            success, top_result = get_top_users(args.URL, header, args)
            if success:
                accounts = top_result
            else:
                top_error = top_result
    else:
        # do not call the API, put in test data. args.TEST[0] is the fixture base path;
        # the plugin appends `-info` (the server info document) and, for scenarios that
        # exercise `--top`, `-users` (the account listing).
        test_base = args.TEST[0]
        result = lib.lftest.test_json(args.TEST, f'{test_base}-info')
        if args.TOP > 0:
            accounts = get_accounts(
                lib.lftest.test_json(args.TEST, f'{test_base}-users')
            )

    # analyze data
    # extract some application specific data
    nc_system_apps_num_installed = result['ocs']['data']['nextcloud']['system']['apps'][
        'num_installed'
    ]
    nc_system_apps_num_updates_available = result['ocs']['data']['nextcloud']['system'][
        'apps'
    ]['num_updates_available']
    nc_system_memcache_local = result['ocs']['data']['nextcloud']['system'][
        'memcache.local'
    ]
    nc_system_memcache_locking = result['ocs']['data']['nextcloud']['system'][
        'memcache.locking'
    ]
    nc_system_version = result['ocs']['data']['nextcloud']['system']['version']

    nc_storage_num_users = result['ocs']['data']['nextcloud']['storage']['num_users']
    nc_storage_num_files = result['ocs']['data']['nextcloud']['storage']['num_files']
    nc_storage_num_storages = result['ocs']['data']['nextcloud']['storage'][
        'num_storages'
    ]
    nc_storage_num_storages_local = result['ocs']['data']['nextcloud']['storage'][
        'num_storages_local'
    ]
    nc_storage_num_storages_home = result['ocs']['data']['nextcloud']['storage'][
        'num_storages_home'
    ]
    nc_storage_num_storages_other = result['ocs']['data']['nextcloud']['storage'][
        'num_storages_other'
    ]

    nc_shares_num_fed_shares_received = result['ocs']['data']['nextcloud']['shares'][
        'num_fed_shares_received'
    ]
    nc_shares_num_fed_shares_sent = result['ocs']['data']['nextcloud']['shares'][
        'num_fed_shares_sent'
    ]

    nc_shares_num_shares = result['ocs']['data']['nextcloud']['shares']['num_shares']
    nc_shares_num_shares_groups = result['ocs']['data']['nextcloud']['shares'][
        'num_shares_groups'
    ]
    nc_shares_num_shares_link = result['ocs']['data']['nextcloud']['shares'][
        'num_shares_link'
    ]
    nc_shares_num_shares_link_no_password = result['ocs']['data']['nextcloud'][
        'shares'
    ]['num_shares_link_no_password']
    nc_shares_num_shares_mail = result['ocs']['data']['nextcloud']['shares'].get(
        'num_shares_mail', 'n/a'
    )
    nc_shares_num_shares_room = result['ocs']['data']['nextcloud']['shares'].get(
        'num_shares_room', 'n/a'
    )
    nc_shares_num_shares_user = result['ocs']['data']['nextcloud']['shares'][
        'num_shares_user'
    ]

    nc_server_php_max_execution_time = result['ocs']['data']['server']['php'][
        'max_execution_time'
    ]
    nc_server_php_memory_limit = result['ocs']['data']['server']['php']['memory_limit']
    nc_server_php_upload_max_filesize = result['ocs']['data']['server']['php'][
        'upload_max_filesize'
    ]
    nc_server_php_version = result['ocs']['data']['server']['php']['version']
    nc_server_webserver = result['ocs']['data']['server']['webserver']

    nc_server_database_size = int(result['ocs']['data']['server']['database']['size'])
    nc_server_database_type = result['ocs']['data']['server']['database']['type']
    nc_server_database_version = result['ocs']['data']['server']['database']['version']

    nc_active_users_last5min = result['ocs']['data']['activeUsers']['last5minutes']
    nc_active_users_last1h = result['ocs']['data']['activeUsers']['last1hour']
    nc_active_users_last24h = result['ocs']['data']['activeUsers']['last24hours']

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

    # build the (long) multiline message
    msg += (
        f'{nc_storage_num_users} users'
        f' ({nc_active_users_last5min}'
        f'/{nc_active_users_last1h}'
        f'/{nc_active_users_last24h}'
        f' in the last 5min/1h/24h),'
        f' {lib.human.number2human(nc_storage_num_files)} files,'
        f' {nc_system_apps_num_installed} apps'
        f' ({nc_system_apps_num_updates_available}'
        f' {lib.txt.pluralize("update", nc_system_apps_num_updates_available)}'
        f' available),'
        f' v{nc_system_version}\n'
    )

    # build the message
    msg += (
        f'* Shares: {nc_shares_num_shares}'
        f' ({nc_shares_num_shares_groups}'
        f' {lib.txt.pluralize("group", nc_shares_num_shares_groups)},'
        f' {nc_shares_num_shares_link}'
        f' {lib.txt.pluralize("link", nc_shares_num_shares_link)}'
        f' [{nc_shares_num_shares_link_no_password} w/o password],'
        f' {nc_shares_num_shares_mail}'
        f' {lib.txt.pluralize("mail", nc_shares_num_shares_mail)},'
        f' {nc_shares_num_shares_room}'
        f' {lib.txt.pluralize("room", nc_shares_num_shares_room)},'
        f' {nc_shares_num_shares_user}'
        f' {lib.txt.pluralize("user", nc_shares_num_shares_user)},'
        f' {nc_shares_num_fed_shares_sent}'
        f' federated sent)\n'
    )
    msg += f'* Federated Shares: {nc_shares_num_fed_shares_received} received\n'
    msg += (
        f'* Storages: {nc_storage_num_storages}'
        f' ({nc_storage_num_storages_home} home,'
        f' {nc_storage_num_storages_other} other,'
        f' {nc_storage_num_storages_local} local)\n'
    )
    msg += (
        f'* PHP: v{nc_server_php_version},'
        f' upload_max_filesize='
        f'{lib.human.bytes2human(nc_server_php_upload_max_filesize)},'
        f' max_execution_time='
        f'{nc_server_php_max_execution_time}s,'
        f' memory_limit='
        f'{lib.human.bytes2human(nc_server_php_memory_limit)}\n'
    )
    msg += (
        f'* DB: {nc_server_database_type}'
        f' v{nc_server_database_version},'
        f' size='
        f'{lib.human.bytes2human(nc_server_database_size)}\n'
    )
    local_mc = nc_system_memcache_local.replace('\\OC\\', '')
    locking_mc = nc_system_memcache_locking.replace('\\OC\\', '')
    msg += (
        f'* Web: {nc_server_webserver},'
        f' local memcache: {local_mc},'
        f' locking memcache: {locking_mc}\n'
    )

    # The account listing closes the output. It carries no state and no perfdata, it only
    # answers who fills up the data directory once something else reported it filling up.
    if args.TOP > 0 and top_error:
        msg += f'\nTop {args.TOP} accounts by storage usage: unavailable, {top_error}\n'
    elif args.TOP > 0 and not accounts:
        # The listing answered, but named nobody. An account whose storage could not be
        # read is dropped on the way here, and the endpoint reports only the accounts the
        # user behind --username is allowed to see. Saying so beats dropping the section
        # without a word, which reads like the instance has no accounts.
        msg += (
            f'\nTop {args.TOP} accounts by storage usage: none reported,'
            f' check that {args.USERNAME} may list accounts.\n'
        )
    else:
        msg += format_top(accounts, args.TOP)

    perfdata += lib.base.get_perfdata(
        'nc_system_apps_num_installed',
        nc_system_apps_num_installed,
        _min=0,
    )

    perfdata += lib.base.get_perfdata(
        'nc_storage_num_users',
        nc_storage_num_users,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'nc_storage_num_files',
        nc_storage_num_files,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'nc_storage_num_storages',
        nc_storage_num_storages,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'nc_storage_num_storages_local',
        nc_storage_num_storages_local,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'nc_storage_num_storages_home',
        nc_storage_num_storages_home,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'nc_storage_num_storages_other',
        nc_storage_num_storages_other,
        _min=0,
    )

    perfdata += lib.base.get_perfdata(
        'nc_shares_num_fed_shares_received',
        nc_shares_num_fed_shares_received,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'nc_shares_num_fed_shares_sent',
        nc_shares_num_fed_shares_sent,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'nc_shares_num_shares',
        nc_shares_num_shares,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'nc_shares_num_shares_groups',
        nc_shares_num_shares_groups,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'nc_shares_num_shares_link',
        nc_shares_num_shares_link,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'nc_shares_num_shares_link_no_password',
        nc_shares_num_shares_link_no_password,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'nc_shares_num_shares_mail',
        nc_shares_num_shares_mail,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'nc_shares_num_shares_room',
        nc_shares_num_shares_room,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'nc_shares_num_shares_user',
        nc_shares_num_shares_user,
        _min=0,
    )

    perfdata += lib.base.get_perfdata(
        'nc_server_database_size',
        nc_server_database_size,
        uom='B',
        _min=0,
    )

    perfdata += lib.base.get_perfdata(
        'nc_active_users_last5min',
        nc_active_users_last5min,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'nc_active_users_last1h',
        nc_active_users_last1h,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'nc_active_users_last24h',
        nc_active_users_last24h,
        _min=0,
    )

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