#!/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

import lib.args
import lib.base
import lib.human
import lib.shell
import lib.url
from lib.globals import STATE_OK, STATE_UNKNOWN, STATE_WARN

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

DESCRIPTION = """Checks PHP configuration and health, including startup errors, missing modules, and
misconfigured php.ini directives. Optionally reads extended PHP information from a
monitoring helper script deployed in the web server context.
Alerts on startup errors, missing modules, or insecure settings.
Requires root or sudo."""

DEFAULT_CONFIG = ['date.timezone=Europe']
DEFAULT_CRIT = None
DEFAULT_INSECURE = False
DEFAULT_MODULES = [
    'calendar',
    'Core',
    'ctype',
    'date',
    'exif',
    'fileinfo',
    'filter',
    'ftp',
    'gettext',
    'hash',
    'iconv',
    'json',
    'libxml',
    'openssl',
    'pcntl',
    'pcre',
    'Phar',
    'readline',
    'Reflection',
    'session',
    'sockets',
    'SPL',
    'standard',
    'tokenizer',
    'xml',
    'zlib',
]
DEFAULT_NO_PROXY = False
DEFAULT_TIMEOUT = 8
DEFAULT_URL = 'http://localhost/monitoring.php'
DEFAULT_WARN = 90


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 Opcache usage, in percent. Default: >= %(default)s',
        dest='CRIT',
        type=lib.args.int_or_none,
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--config',
        help='PHP ini "key=value" pair to check (startswith match). '
        'Can be specified multiple times. '
        'Example: `--config "memory_limit=128M"`',
        dest='CONFIG',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--dev',
        help='Development mode. '
        'Tolerates `display_errors=On` and `display_startup_errors=On`.',
        dest='DEV',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--module',
        help='PHP module name to check (startswith match). '
        'Can be specified multiple times. '
        'Example: `--module json --module mbstring`',
        dest='MODULES',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--insecure',
        help=lib.args.help('--insecure'),
        dest='INSECURE',
        action='store_true',
        default=DEFAULT_INSECURE,
    )

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

    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='URL to the optional PHP `monitoring.php` helper script. '
        'Without it the plugin still works, but with reduced accuracy. '
        'Default: %(default)s',
        dest='URL',
        default=DEFAULT_URL,
    )

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for Opcache usage, in percent. Default: >= %(default)s',
        dest='WARN',
        type=lib.args.int_or_none,
        default=DEFAULT_WARN,
    )

    args, _ = parser.parse_known_args()
    return args


def get_startup_errors():
    success, result = lib.shell.shell_exec('php --version 1> /dev/null')
    if not success:
        lib.base.cu('PHP not found')
    _, stderr, _ = result
    return stderr


def get_config_errors(args):
    success, result = lib.shell.shell_exec('php --info 2> /dev/null')
    if not success:
        lib.base.cu('PHP not found')
    stdout, _, _ = result
    php_config = stdout.lower()
    result = ''
    for config in args.CONFIG:
        try:
            key, value = config.split('=')
            key = key.strip()
            value = value.strip()
        except Exception:
            continue
        if f'{key.lower()} => {value.lower()}' not in php_config:
            result += f'{key} = {value}, '
    if result:
        result = result[:-2] + ' (did you run the check with sudo?), '
    return result


def get_module(args):
    success, result = lib.shell.shell_exec('php --modules 2> /dev/null')
    if not success:
        lib.base.cu('PHP not found')
    stdout, _, _ = result
    php_modules = stdout.lower()
    result = ''
    for module in args.MODULES:
        if module.lower() not in php_modules:
            result += f'{module}, '
    return result


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)

    # set default values for append parameters that were not specified
    if args.CONFIG is None:
        args.CONFIG = DEFAULT_CONFIG
    if args.MODULES is None:
        args.MODULES = DEFAULT_MODULES

    # init some vars
    msg = ''
    state = STATE_OK
    perfdata = ''
    php = {}
    table_values = []

    # analyze data
    success, php = lib.url.fetch_json(
        args.URL,
        insecure=args.INSECURE,
        no_proxy=args.NO_PROXY,
        timeout=args.TIMEOUT,
    )
    if not success:

        # build the message
        msg += 'monitoring.php not found. '

    # section "php.conf" from monitoring.php
    if isinstance(php, dict) and php.get('php.conf'):
        msg += f'PHP v{php.get("php.conf").get("php.version", "N/A")}'
        msg += f' ({php.get("php.conf").get("php.ini", "N/A")}), '

    # section "php.ini" from monitoring.php, print as key-value-table
    if isinstance(php, dict) and php.get('php.ini'):
        table_values.append(
            {
                'key': 'date.timezone',
                'val': php.get('php.ini').get('date.timezone', 'N/A'),
            }
        )
        table_values.append(
            {
                'key': 'default_socket_timeout',
                'val': php.get('php.ini').get('default_socket_timeout', 'N/A'),
            }
        )
        val = php.get('php.ini').get('display_errors', 'N/A')
        if val in ('On', '1', 'N/A') and not args.DEV:
            val = val + lib.base.state2str(STATE_WARN, prefix=' ')
            state = lib.base.get_worst(state, STATE_WARN)
        table_values.append({'key': 'display_errors', 'val': val})
        val = php.get('php.ini').get('display_startup_errors', 'N/A')
        if val in ('On', '1', 'N/A') and not args.DEV:
            val = val + lib.base.state2str(STATE_WARN, prefix=' ')
            state = lib.base.get_worst(state, STATE_WARN)
        table_values.append({'key': 'display_startup_errors', 'val': val})
        table_values.append(
            {
                'key': 'error_reporting',
                'val': php.get('php.ini').get('error_reporting', 'N/A'),
            }
        )
        val = php.get('php.ini').get('expose_php', 'N/A')
        if val in ('On', '1', 'N/A'):
            val = val + lib.base.state2str(STATE_WARN, prefix=' ')
            state = lib.base.get_worst(state, STATE_WARN)
        table_values.append({'key': 'expose_php', 'val': val})
        table_values.append(
            {
                'key': 'max_execution_time',
                'val': php.get('php.ini').get('max_execution_time', 'N/A'),
            }
        )
        table_values.append(
            {
                'key': 'max_file_uploads',
                'val': php.get('php.ini').get('max_file_uploads', 'N/A'),
            }
        )
        table_values.append(
            {
                'key': 'max_input_time',
                'val': php.get('php.ini').get('max_input_time', 'N/A'),
            }
        )
        table_values.append(
            {
                'key': 'memory_limit',
                'val': php.get('php.ini').get('memory_limit', 'N/A'),
            }
        )
        table_values.append(
            {
                'key': 'post_max_size',
                'val': php.get('php.ini').get('post_max_size', 'N/A'),
            }
        )
        table_values.append(
            {'key': 'SMTP', 'val': php.get('php.ini').get('SMTP', 'N/A')}
        )
        table_values.append(
            {
                'key': 'upload_max_filesize',
                'val': php.get('php.ini').get('upload_max_filesize', 'N/A'),
            }
        )

    # checking PHP Opcache module
    # section "opcache_status" from monitoring.php
    if (
        isinstance(php, dict)
        and php.get('opcache_status')
        and php.get('opcache_status').get('opcache_enabled') is True
    ):
        if php.get('opcache_status').get('cache_full', 'N/A') is True:
            state = lib.base.get_worst(state, STATE_WARN)
            msg += f'Opcache is full{lib.base.state2str(STATE_WARN, prefix=" ")}, '
        if php.get('opcache_status').get('restart_pending', 'N/A') is True:
            state = lib.base.get_worst(state, STATE_WARN)
            msg += (
                f'Opcache pending restart{lib.base.state2str(STATE_WARN, prefix=" ")}, '
            )
        if php.get('opcache_status').get('restart_in_progress', 'N/A') is True:
            state = lib.base.get_worst(state, STATE_WARN)
            msg += f'Opcache restarting{lib.base.state2str(STATE_WARN, prefix=" ")}, '

        # Opcache Memory Usage (opcache.memory_consumption)
        mem_used = php.get('opcache_status').get('memory_usage').get('used_memory')
        mem_free = php.get('opcache_status').get('memory_usage').get('free_memory')
        mem_total = mem_used + mem_free
        mem_used_percentage = round(float(mem_used) / float(mem_total) * 100, 1)
        opcache_state = lib.base.get_state(mem_used_percentage, args.WARN, args.CRIT)
        state = lib.base.get_worst(opcache_state, state)
        msg += (
            f'Opcache Mem {mem_used_percentage}% used ({lib.human.bytes2human(mem_used)}/'
            f'{lib.human.bytes2human(mem_total)})'
            f'{lib.base.state2str(opcache_state, prefix=" ")}, '
        )

        wmem_used = php.get('opcache_status').get('memory_usage').get('wasted_memory')
        wmem_used_percentage = round(
            php.get('opcache_status')
            .get('memory_usage')
            .get('current_wasted_percentage'),
            1,
        )
        wmem_total_percentage = (
            php.get('opcache_config')
            .get('directives')
            .get('opcache.max_wasted_percentage')
            * 100
        )  # Default: 5%
        msg += (
            f'Wasted {wmem_used_percentage}% '
            f'({lib.human.bytes2human(wmem_used)}, max. {wmem_total_percentage}%), '
        )

        # Opcache Key Usage (opcache.max_accelerated_files)
        keys_cached = (
            php.get('opcache_status').get('opcache_statistics').get('num_cached_keys')
        )
        #  number out of a set of prime numbers "ge" to opcache.max_accelerated_files:
        keys_total = (
            php.get('opcache_status').get('opcache_statistics').get('max_cached_keys')
        )
        keys_free = keys_total - keys_cached
        keys_cached_percentage = round(float(keys_cached) / float(keys_total) * 100, 1)
        opcache_state = lib.base.get_state(keys_cached_percentage, args.WARN, args.CRIT)
        state = lib.base.get_worst(opcache_state, state)
        msg += (
            f'Keys {keys_cached_percentage}% used '
            f'({keys_cached}/{keys_total}){lib.base.state2str(opcache_state, prefix=" ")}, '
        )

        # Opcache Hit Rate
        cache_hits = php.get('opcache_status').get('opcache_statistics').get('hits')
        cache_misses = php.get('opcache_status').get('opcache_statistics').get('misses')
        cache_hit_rate = round(
            php.get('opcache_status').get('opcache_statistics').get('opcache_hit_rate'),
            1,
        )
        msg += (
            f'Hit Rate {cache_hit_rate}% ({lib.human.number2human(cache_hits)} hits, '
            f'{lib.human.number2human(cache_misses)} misses), '
        )

        # Opcache Interned String Buffer (opcache.interned_strings_buffer)
        string_total = (
            php.get('opcache_status').get('interned_strings_usage').get('buffer_size')
        )
        string_used = (
            php.get('opcache_status').get('interned_strings_usage').get('used_memory')
        )
        string_free = (
            php.get('opcache_status').get('interned_strings_usage').get('free_memory')
        )
        string_number = (
            php.get('opcache_status')
            .get('interned_strings_usage')
            .get('number_of_strings')
        )
        string_percentage = round(float(string_used) / float(string_total) * 100, 1)
        opcache_state = lib.base.get_state(string_percentage, args.WARN, args.CRIT)
        state = lib.base.get_worst(opcache_state, state)
        msg += (
            f'Interned Strings {string_percentage}% used '
            f'({lib.human.bytes2human(string_used)}/{lib.human.bytes2human(string_total)}, '
            f'{string_number} Strings){lib.base.state2str(opcache_state, prefix=" ")}, '
        )

        # Opcache Restarts
        restarts_oom = (
            php.get('opcache_status').get('opcache_statistics').get('oom_restarts')
        )
        restarts_manual = (
            php.get('opcache_status').get('opcache_statistics').get('manual_restarts')
        )
        restarts_keys = (
            php.get('opcache_status').get('opcache_statistics').get('hash_restarts')
        )
        opcache_state = lib.base.get_state(restarts_oom, 1, None)
        state = lib.base.get_worst(opcache_state, state)
        msg += (
            f'{restarts_oom} OOM{lib.base.state2str(opcache_state, prefix=" ")} / '
            f'{restarts_manual} manual / {restarts_keys} key restarts, '
        )

        perfdata += lib.base.get_perfdata(
            'php-opcache-memory_usage-percentage',
            mem_used_percentage,
            uom='%',
            warn=args.WARN,
            crit=args.CRIT,
            _min=0,
            _max=100,
        )
        perfdata += lib.base.get_perfdata(
            'php-opcache-memory_usage-used_memory',
            int(mem_used),
            uom='B',
            warn=None,
            crit=None,
            _min=0,
            _max=mem_total,
        )
        perfdata += lib.base.get_perfdata(
            'php-opcache-memory_usage-free_memory',
            int(mem_free),
            uom='B',
            warn=None,
            crit=None,
            _min=0,
            _max=mem_total,
        )
        perfdata += lib.base.get_perfdata(
            'php-opcache-memory_usage-wasted_memory',
            wmem_used,
            uom='B',
            warn=None,
            crit=None,
            _min=0,
            _max=mem_total,
        )
        perfdata += lib.base.get_perfdata(
            'php-opcache-memory_usage-current_wasted-percentage',
            wmem_used_percentage,
            uom='%',
            warn=None,
            crit=None,
            _min=0,
            _max=wmem_total_percentage,
        )

        perfdata += lib.base.get_perfdata(
            'php-opcache-opcache_statistics-num_cached_scripts',
            php.get('opcache_status')
            .get('opcache_statistics')
            .get('num_cached_scripts'),
            uom=None,
            warn=None,
            crit=None,
            _min=0,
            _max=keys_total,
        )
        perfdata += lib.base.get_perfdata(
            'php-opcache-opcache_statistics-num_cached_keys',
            keys_cached,
            uom=None,
            warn=None,
            crit=None,
            _min=0,
            _max=keys_total,
        )
        perfdata += lib.base.get_perfdata(
            'php-opcache-opcache_statistics-num_free_keys',
            keys_free,
            uom=None,
            warn=None,
            crit=None,
            _min=0,
            _max=keys_total,
        )
        perfdata += lib.base.get_perfdata(
            'php-opcache-opcache_statistics-num_cached_keys-percentage',
            keys_cached_percentage,
            uom='%',
            warn=args.WARN,
            crit=args.CRIT,
            _min=0,
            _max=100,
        )

        perfdata += lib.base.get_perfdata(
            'php-opcache-opcache_statistics-hits',
            cache_hits,
            uom='c',
            warn=None,
            crit=None,
            _min=0,
            _max=None,
        )
        perfdata += lib.base.get_perfdata(
            'php-opcache-opcache_statistics-misses',
            cache_misses,
            uom='c',
            warn=None,
            crit=None,
            _min=0,
            _max=None,
        )
        perfdata += lib.base.get_perfdata(
            'php-opcache-opcache_statistics-opcache_hit_rate',
            cache_hit_rate,
            uom='%',
            warn=None,
            crit=None,
            _min=0,
            _max=100,
        )

        perfdata += lib.base.get_perfdata(
            'php-opcache-interned_strings_usage-percentage',
            string_percentage,
            uom='%',
            warn=args.WARN,
            crit=args.CRIT,
            _min=0,
            _max=100,
        )
        perfdata += lib.base.get_perfdata(
            'php-opcache-interned_strings_usage-used_memory',
            string_used,
            uom='B',
            warn=None,
            crit=None,
            _min=0,
            _max=string_total,
        )
        perfdata += lib.base.get_perfdata(
            'php-opcache-interned_strings_usage-free_memory',
            string_free,
            uom='B',
            warn=None,
            crit=None,
            _min=0,
            _max=string_total,
        )
        perfdata += lib.base.get_perfdata(
            'php-opcache-interned_strings_usage-number_of_strings',
            string_number,
            uom=None,
            warn=None,
            crit=None,
            _min=0,
            _max=None,
        )

        perfdata += lib.base.get_perfdata(
            'php-opcache-opcache_statistics-oom_restarts',
            restarts_oom,
            uom=None,
            warn=None,
            crit=None,
            _min=0,
            _max=None,
        )
        perfdata += lib.base.get_perfdata(
            'php-opcache-opcache_statistics-hash_restarts',
            restarts_keys,
            uom=None,
            warn=None,
            crit=None,
            _min=0,
            _max=None,
        )
        perfdata += lib.base.get_perfdata(
            'php-opcache-opcache_statistics-manual_restarts',
            restarts_manual,
            uom=None,
            warn=None,
            crit=None,
            _min=0,
            _max=None,
        )

        perfdata += lib.base.get_perfdata(
            'php-opcache-opcache_statistics-blacklist_misses',
            php.get('opcache_status').get('opcache_statistics').get('blacklist_misses'),
            uom=None,
            warn=None,
            crit=None,
            _min=0,
            _max=None,
        )
        perfdata += lib.base.get_perfdata(
            'php-opcache-opcache_statistics-blacklist_miss_ratio',
            php.get('opcache_status')
            .get('opcache_statistics')
            .get('blacklist_miss_ratio'),
            uom='%',
            warn=args.WARN,
            crit=args.CRIT,
            _min=0,
            _max=100,
        )

        table_values.append(
            {
                'key': 'opcache.blacklist_filename',
                'val': php.get('opcache_config')
                .get('directives')
                .get('opcache.blacklist_filename'),
            }
        )
        table_values.append(
            {
                'key': 'opcache.enable',
                'val': php.get('opcache_config')
                .get('directives')
                .get('opcache.enable'),
            }
        )
        table_values.append(
            {
                'key': 'opcache.enable_cli',
                'val': php.get('opcache_config')
                .get('directives')
                .get('opcache.enable_cli'),
            }
        )
        table_values.append(
            {
                'key': 'opcache.huge_code_pages',
                'val': php.get('opcache_config')
                .get('directives')
                .get('opcache.huge_code_pages'),
            }
        )
        table_values.append(
            {
                'key': 'opcache.interned_strings_buffer',
                'val': php.get('opcache_config')
                .get('directives')
                .get('opcache.interned_strings_buffer'),
            }
        )
        table_values.append(
            {
                'key': 'opcache.max_accelerated_files',
                'val': php.get('opcache_config')
                .get('directives')
                .get('opcache.max_accelerated_files'),
            }
        )
        table_values.append(
            {
                'key': 'opcache.memory_consumption',
                'val': lib.human.bytes2human(
                    php.get('opcache_config')
                    .get('directives')
                    .get('opcache.memory_consumption')
                ),
            }
        )
        table_values.append(
            {
                'key': 'opcache.revalidate_freq',
                'val': php.get('opcache_config')
                .get('directives')
                .get('opcache.revalidate_freq'),
            }
        )
        table_values.append(
            {
                'key': 'opcache.save_comments',
                'val': php.get('opcache_config')
                .get('directives')
                .get('opcache.save_comments'),
            }
        )
        table_values.append(
            {
                'key': 'opcache.validate_timestamps',
                'val': 'True'
                if php.get('opcache_config')
                .get('directives')
                .get('opcache.validate_timestamps')
                else 'False',
            }
        )

    if (
        isinstance(php, dict)
        and php.get('opcache_status')
        and php.get('opcache_status').get('opcache_enabled') is False
    ):
        msg += f'Opcache not installed or not enabled{lib.base.state2str(STATE_WARN, prefix=" ")}, '
        state = lib.base.get_worst(state, STATE_WARN)

    startup_errors = get_startup_errors()
    if startup_errors:
        msg += f'Startup errors: {startup_errors} {lib.base.state2str(STATE_WARN, prefix="")}, '
        state = lib.base.get_worst(state, STATE_WARN)
        perfdata += lib.base.get_perfdata(
            'php-startup-errors',
            1,
            uom=None,
            warn=1,
            crit=2,
            _min=0,
            _max=3,
        )
    else:
        msg += 'No startup errors were detected. '
        perfdata += lib.base.get_perfdata(
            'php-startup-errors',
            0,
            uom=None,
            warn=1,
            crit=2,
            _min=0,
            _max=3,
        )

    config_errors = get_config_errors(args)
    if config_errors:
        msg += (
            f'Config expected but not found: {config_errors[:-2]} '
            f'{lib.base.state2str(STATE_WARN, prefix="")}, '
        )
        state = lib.base.get_worst(state, STATE_WARN)
        perfdata += lib.base.get_perfdata(
            'php-config-errors',
            1,
            uom=None,
            warn=1,
            crit=2,
            _min=0,
            _max=3,
        )
    else:
        msg += 'No unexpected configurations detected. '
        perfdata += lib.base.get_perfdata(
            'php-config-errors',
            0,
            uom=None,
            warn=1,
            crit=2,
            _min=0,
            _max=3,
        )

    module_errors = get_module(args)
    if module_errors:
        msg += (
            f'Modules expected but not found: {module_errors[:-2]} '
            f'{lib.base.state2str(STATE_WARN, prefix="")}, '
        )
        state = lib.base.get_worst(state, STATE_WARN)
        perfdata += lib.base.get_perfdata(
            'php-module-errors',
            1,
            uom=None,
            warn=1,
            crit=2,
            _min=0,
            _max=3,
        )
    else:
        msg += 'All expected modules were found.'
        perfdata += lib.base.get_perfdata(
            'php-module-errors',
            0,
            uom=None,
            warn=1,
            crit=2,
            _min=0,
            _max=3,
        )

    # get the message
    msg = f'Everything is ok. {msg}\n\n' if state == STATE_OK else msg[:-2] + '\n\n'
    if len(table_values) > 0:
        msg += lib.base.get_table(
            table_values,
            ['key', 'val'],
            header=['Key', 'Value'],
        )

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


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