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

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

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

DESCRIPTION = """Monitors the health status of a WHMCS installation via its system status
API, which reports on the WHMCS version, the PHP and database environment, cron runs,
file permissions and TLS. Alerts when WHMCS reports a check as more than a notice, and
when it answers without any health checks at all."""

DEFAULT_INSECURE = False
DEFAULT_NO_PROXY = False
DEFAULT_TIMEOUT = 8

# The bodies WHMCS returns are HTML. Block-level tags carry the line breaks; dropping
# them without a replacement runs sentences together ("...upgrade.You are currently...").
BLOCK_TAGS = re.compile(r'</?(?:br|div|h[1-6]|li|ol|p|ul)\s*/?>', re.IGNORECASE)
LIST_ITEM_END = re.compile(r'</li\s*>', re.IGNORECASE)
REPEATED_PERIOD = re.compile(r'\.(\s*\.)+')

# WHMCS does not document the severity levels it can emit. These are the ones seen in
# the wild; anything else is handled as unknown rather than dropped or fatal.
SEVERITY_ORDER = {'error': 1, 'warning': 2, 'info': 3}


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(
        '--identifier',
        help='WHMCS API identifier.',
        dest='IDENTIFIER',
        required=True,
    )

    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(
        '-p',
        '--password',
        help='HTTP Basic Auth password.',
        dest='PASSWORD',
    )

    parser.add_argument(
        '--proxy',
        help=lib.args.help('--proxy'),
        dest='PROXY',
        default=None,
    )

    parser.add_argument(
        '--secret',
        help='WHMCS API secret.',
        dest='SECRET',
        required=True,
    )

    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(
        '--url',
        help='Base URL of the WHMCS installation, without the trailing '
        '`/includes/api.php`. '
        'Example: `--url=https://whmcs.example.com`',
        dest='URL',
        required=True,
    )

    parser.add_argument(
        '--username',
        help='HTTP Basic Auth username.',
        dest='USERNAME',
    )

    args, _ = parser.parse_known_args()
    return args


def get_data(args):
    """Login to WHCMS, call the API and return JSON data."""
    header = {}
    if args.USERNAME and args.PASSWORD:
        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}'
    return lib.url.fetch_json(
        f'{args.URL}/includes/api.php',
        data={
            'identifier': args.IDENTIFIER,
            'secret': args.SECRET,
            'action': 'GetHealthStatus',
            'fetchStatus': 'true',
            'responsetype': 'json',
        },
        encoding='urlencode',
        header=header,
        insecure=args.INSECURE,
        no_proxy=args.NO_PROXY,
        proxy=args.PROXY,
        timeout=args.TIMEOUT,
    )


def body2text(body):
    """Turn the HTML body of one health check into a single line of plain text."""
    if not isinstance(body, str):
        return ''
    text = body.replace('<strong>', '*').replace('</strong>', '*')
    # A list item is a sentence of its own, every other block tag is just a break.
    text = LIST_ITEM_END.sub('. ', text)
    text = BLOCK_TAGS.sub(' ', text)
    text = lib.url.strip_tags(text)
    # strip_tags() removes the tags but leaves the entities behind them.
    text = html.unescape(text)
    text = ' '.join(text.split())
    # A list item that already ended in a period must not gain a second one.
    return REPEATED_PERIOD.sub('.', text)


def get_failed_checks(result):
    """Return the health checks that are more than a notice, worst severity first.

    Returns `(False, errormessage)` when the answer holds no health check at all. That is
    not a healthy installation, it is an answer the plugin cannot read, and reporting it
    as OK would state an all-clear nobody verified. WHMCS answers exactly like that when
    the API role does not grant the `GetHealthStatus` action.
    """
    groups = result.get('checks')
    groups = groups.values() if isinstance(groups, dict) else []

    seen = 0
    failed_checks = []
    for items in groups:
        # A group holding no check at all is `null`, which is the normal case.
        if not isinstance(items, list):
            continue
        for item in items:
            if not isinstance(item, dict):
                continue
            seen += 1
            severity = str(item.get('severityLevel') or 'unknown')
            if severity == 'notice':
                continue
            failed_checks.append(
                {
                    'severity': severity,
                    # Falling back to the check's own name keeps the item identifiable
                    # even when WHMCS sends it without a body.
                    'text': body2text(item.get('body'))
                    or str(item.get('name') or 'no message'),
                    'type': str(item.get('type') or ''),
                }
            )

    if not seen:
        return (
            False,
            'WHMCS answered, but the answer holds no health check. Grant the '
            '"GetHealthStatus" action to the API role this check authenticates with.',
        )

    return (
        True,
        sorted(
            failed_checks,
            # A severity WHMCS did not have when this was written sorts to the top: the
            # plugin cannot judge how bad it is and must not bury it below the known ones.
            key=lambda x: (SEVERITY_ORDER.get(x['severity'], 0), x['type'], x['text']),
        ),
    )


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:
        result = lib.base.coe(get_data(args))
    else:
        # do not call the API, put in test data
        result = lib.lftest.test_json(args.TEST)
    if result.get('result') != 'success':
        # The API states the reason itself, and that is the sentence which helps: a wrong
        # credential, an unauthorized action and a blocked source address all look alike
        # from the outside. Some of those messages end in a period and some do not, so
        # strip it and punctuate here instead of printing "went wrong.. Check ...".
        reason = str(result.get('message') or 'no reason given').rstrip(' .')
        # A refused request is the expected answer here, not a defect, so no stack trace.
        lib.base.cu(
            f'The WHMCS API refused the request: {reason}. Check the API credentials, '
            f'the actions the API role allows, and the API IP access restriction.',
            traceback=False,
        )

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

    # analyze data
    failed_checks = lib.base.coe(get_failed_checks(result))

    # build the message
    for item in failed_checks:
        prefix = f'{item["type"]}: ' if item['type'] else ''
        msg += f'* {prefix}{item["text"]}'
        msg += f' ({item["severity"]})'
        if item['severity'] != 'info':
            state = STATE_WARN
            msg += lib.base.state2str(STATE_WARN, prefix=' ')
        msg += '\n'

    if msg:
        msg_header = (
            f'There '
            f'{lib.txt.pluralize("", len(failed_checks), "is,are")} '
            f'{len(failed_checks)} {lib.txt.pluralize("message", len(failed_checks))}'
        )
        if len(failed_checks) > 1:
            msg_header = f'{msg_header}, ordered by severity'
        msg = f'{msg_header}.\n\n{msg}'
    else:
        msg = 'Everything is ok.'

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