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

import lib.args
import lib.base
import lib.lftest
import lib.powershell
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN

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

DESCRIPTION = """Monitors IPv4 DHCP scope usage on a Windows DHCP server. Runs locally on the
DHCP server or connects via WinRM, and queries scope statistics using PowerShell. On
servers with thousands of scopes, --brief hides rows within the thresholds so the
output only lists scopes in WARN/CRIT state.
Alerts when the address pool usage of any scope exceeds the configured thresholds
(default: WARN at 80%, CRIT at 90%)."""

DEFAULT_CRIT = 90
DEFAULT_HOSTNAME = 'localhost'
# Loading the DhcpServer module alone takes 15 to 22 seconds per PowerShell session on
# Windows Server 2025 (any CDXML module does, measured locally and over WinRM on a
# 4 vCPU VM), against well below a second on Server 2016 to 2022. The cmdlets
# themselves answer within 0.3 seconds.
DEFAULT_TIMEOUT = 25
DEFAULT_WARN = 80
DEFAULT_WINRM_DOMAIN = None
DEFAULT_WINRM_TRANSPORT = 'ntlm'
DEFAULT_WINRM_USERNAME = 'Administrator'


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(
        '--brief',
        help='Hide scopes within the thresholds and show only those in WARN/CRIT state. '
        'Perfdata and alerting are unaffected: all scopes still emit perfdata and '
        'still drive the overall check state.',
        dest='BRIEF',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '-c',
        '--critical',
        help=lib.args.help('--critical') + ' Default: >= %(default)s',
        dest='CRIT',
        type=int,
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '-H',
        '--hostname',
        help='DNS name, IPv4, or IPv6 address of the DHCP server. Default: %(default)s',
        dest='HOSTNAME',
        default=DEFAULT_HOSTNAME,
    )

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

    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=lib.args.help('--warning') + ' Default: >= %(default)s',
        dest='WARN',
        type=int,
        default=DEFAULT_WARN,
    )

    parser.add_argument(
        '--winrm-domain',
        help=lib.args.help('--winrm-domain'),
        dest='WINRM_DOMAIN',
        default=DEFAULT_WINRM_DOMAIN,
    )

    parser.add_argument(
        '--winrm-hostname',
        help='Target Windows computer on which the command will be executed. '
        'Without it, the command runs on the local Windows host.',
        dest='WINRM_HOSTNAME',
    )

    parser.add_argument(
        '--winrm-password',
        help=lib.args.help('--winrm-password'),
        dest='WINRM_PASSWORD',
    )

    parser.add_argument(
        '--winrm-transport',
        help=lib.args.help('--winrm-transport') + ' Default: %(default)s',
        dest='WINRM_TRANSPORT',
        choices=['basic', 'ntlm', 'kerberos', 'credssp', 'plaintext'],
        default=DEFAULT_WINRM_TRANSPORT,
    )

    parser.add_argument(
        '--winrm-username',
        help=lib.args.help('--winrm-username') + ' Default: %(default)s',
        dest='WINRM_USERNAME',
        default=DEFAULT_WINRM_USERNAME,
    )

    args, _ = parser.parse_known_args()
    return args


def run_ps_via_winrm(args, cmd):
    """Run a PowerShell command on the host named by --winrm-hostname.

    lib.winrm and the WinRM clients behind it take about 75 ms to import, so they are
    loaded here instead of at the top, where a local run on the DHCP server would pay
    for them without using them.
    """
    import lib.winrm

    return lib.winrm.run_ps(args, cmd, timeout=args.TIMEOUT)


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)

    # --hostname ends up in the PowerShell script that runs on the WinRM host, so only
    # the characters of a DNS name or an IP address are let through; a quote or a
    # `$(...)` would otherwise turn the rest of the value into code. Checked before the
    # test branch so the unit tests cover it. A compressed IPv6 address may start with
    # "::", a leading "-" stays refused.
    if not re.fullmatch(r'[A-Za-z0-9:][A-Za-z0-9.:_-]*', args.HOSTNAME):
        lib.base.cu(
            '--hostname accepts a DNS name, an IPv4 or an IPv6 address only.',
            traceback=False,
        )

    # fetch data
    if args.TEST is None:
        # https://docs.microsoft.com/en-us/powershell/module/dhcpserver/get-dhcpserverv4scopestatistics
        # JSON rather than the formatted table: over PSRP, which lib.winrm prefers, the
        # objects arrive as their type name ("DhcpServerv4ScopeStatistics") and no
        # table is ever rendered, and PercentageInUse would follow the locale. Measured
        # against Windows Server 2025 from Linux (pypsrp).
        # An error of the cmdlet is caught and handed over as JSON as well, so the
        # script only fails when it never ran: a failed WinRM login or connection
        # returns the same exit code 1 and empty stdout as an uncaught cmdlet error.
        cmd = (
            'try {'
            ' ConvertTo-Json -Compress -InputObject @('
            f'Get-DhcpServerv4ScopeStatistics -ComputerName "{args.HOSTNAME}"'
            ' -ErrorAction Stop | '
            'Select-Object @{Name="ScopeId"; Expression={$_.ScopeId.IPAddressToString}},'
            ' Free, InUse, PercentageInUse, Reserved, Pending)'
            ' } catch {'
            ' ConvertTo-Json -Compress -InputObject @{'
            'Error = $_.Exception.Message; Type = $_.Exception.GetType().Name}'
            ' }'
        )
        if args.WINRM_HOSTNAME:
            result = run_ps_via_winrm(args, cmd)
        else:
            if lib.base.LINUX:
                lib.base.cu(
                    'Running this check plugin directly on Linux is not supported.'
                    ' Use the --winrm parameters.',
                    traceback=False,
                )
            result = lib.powershell.run_ps(cmd, timeout=args.TIMEOUT)
        if result['retc'] and result['stderr'].startswith('Timeout after'):
            lib.base.oao(
                f'Timeout after {args.TIMEOUT}s while reading the DHCP scopes.',
                STATE_WARN,
                always_ok=args.ALWAYS_OK,
            )
        stdout, stderr, retc = result['stdout'], result['stderr'], result['retc']
    else:
        # do not call the command, put in test data
        stdout, stderr, retc = lib.lftest.test(args.TEST)

    # nothing ran, so nothing is known about the scopes
    if retc != 0:
        lib.base.cu(
            f'Unable to run the command on {args.WINRM_HOSTNAME or "this host"}:'
            f' {stderr.strip()}',
            traceback=False,
        )

    # init some vars
    state = STATE_OK
    perfdata = ''
    rows = []

    # analyze data
    # [{"ScopeId":"192.0.2.0","Free":1,"InUse":4,"PercentageInUse":80,...}, ...]
    # or the error of the cmdlet: {"Error":"...","Type":"CimException"}
    try:
        scopes = json.loads(stdout)
    except ValueError:
        scopes = None
    if isinstance(scopes, dict) and 'Error' in scopes:
        # Measured against Windows Server 2025 without the DHCP Server tools.
        if scopes.get('Type') == 'CommandNotFoundException':
            lib.base.cu(
                'Get-DhcpServerv4ScopeStatistics is not available on'
                f' {args.WINRM_HOSTNAME or "this host"}: it is no DHCP server, or the'
                ' DHCP Server tools are missing (`Install-WindowsFeature RSAT-DHCP`).',
                traceback=False,
            )
        # The DHCP server does not answer: its service is stopped (WIN32 1753), or the
        # host named by --hostname is unreachable (WIN32 1722). Any other error, such as
        # a CimJobException for an account that may not open the CIM session, says
        # nothing about the server. Measured against Windows Server 2025.
        if scopes.get('Type') == 'CimException':
            lib.base.oao(str(scopes['Error']), STATE_WARN, always_ok=args.ALWAYS_OK)
        lib.base.cu(
            f'Unable to query the DHCP server {args.HOSTNAME}: {scopes["Error"]}',
            traceback=False,
        )
    try:
        scopes = [
            (str(scope['ScopeId']), int(float(scope['PercentageInUse'])))
            for scope in scopes
        ]
    except (KeyError, TypeError, ValueError):
        lib.base.cu(
            'Unable to read the scope statistics the DHCP server returned:\n\n'
            f'{stdout[:500]}',
            traceback=False,
        )
    if not scopes:
        lib.base.oao('No IPv4 scope configured.', STATE_OK, always_ok=args.ALWAYS_OK)

    # get state and collect per-scope rows
    for scope_id, scope_used in scopes:
        scope_state = lib.base.get_state(scope_used, args.WARN, args.CRIT)
        state = lib.base.get_worst(scope_state, state)

        # Perfdata is emitted for every scope regardless of --brief so
        # Grafana sees the full picture and trend lines stay continuous.
        perfdata += lib.base.get_perfdata(
            f'scope_{scope_id}',
            scope_used,
            uom='%',
            warn=args.WARN,
            crit=args.CRIT,
            _min=0,
            _max=100,
        )
        rows.append((scope_id, scope_used, scope_state))

    # Filter rows for --brief display. Perfdata and alerting stay
    # untouched above this point; --brief only reshapes the
    # human-readable output.
    display_rows = [row for row in rows if row[2] != STATE_OK] if args.BRIEF else rows

    # build the message
    headers = {
        STATE_CRIT: 'There are one or more criticals.',
        STATE_WARN: 'There are one or more warnings.',
        STATE_OK: 'Everything is ok.',
    }
    header = headers.get(state, headers[STATE_OK])
    body = ''.join(
        f'* {scope_id}: {scope_used}% used'
        f'{lib.base.state2str(scope_state, prefix=" ")}\n'
        for scope_id, scope_used, scope_state in display_rows
    )
    msg = f'{header}\n\n{body}' if body else header

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