#!/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.lftest
import lib.powershell
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN

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

DESCRIPTION = """Checks the state of a Windows Scheduled Task and the result of its last run. Alerts
when the task is not in one of the expected states, or when its last run failed, for
example because the program returned a non-zero exit code, could not be started, or was
stopped for running too long. Works regardless of the display language of the host."""

DEFAULT_SEVERITY = 'warn'
DEFAULT_STATUS = ['Ready', 'Running']
DEFAULT_TIMEOUT = 8

# Lists every task of the Task Scheduler with its state and the result of its last run.
# The Schedule.Service COM object reports both as numbers, which unlike the status
# column of `schtasks` ("Bereit" on a German host) do not depend on the display
# language. The script contains no caller-supplied value, the task is picked in Python.
# The output encoding is pinned to UTF-8, as PowerShell otherwise writes a pipe in the
# console code page, which differs between a run by hand and one by the monitoring
# agent. Verified with Icinga 2 v2.16.5 on Windows Server 2025 (de-DE).
SCRIPT = r"""
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [Text.Encoding]::UTF8
$service = New-Object -ComObject Schedule.Service
$service.Connect()
$tasks = New-Object System.Collections.ArrayList
function Get-Tasks($folder) {
    foreach ($task in $folder.GetTasks(1)) {
        [void]$tasks.Add([ordered]@{
            path = $task.Path
            state = $task.State
            last_result = $task.LastTaskResult
            last_run = $task.LastRunTime.ToString('yyyy-MM-dd HH:mm:ss')
        })
    }
    foreach ($subfolder in $folder.GetFolders(0)) { Get-Tasks $subfolder }
}
Get-Tasks $service.GetFolder('\')
ConvertTo-Json -Compress -InputObject @($tasks)
"""

# TASK_STATE
TASK_STATES = {
    0: 'Unknown',
    1: 'Disabled',
    2: 'Queued',
    3: 'Ready',
    4: 'Running',
}

SCHED_S_TASK_HAS_NOT_RUN = 0x00041303
SCHED_S_TASK_RUNNING = 0x00041301

# Results of the last run that describe the task instead of reporting a failure. Taken
# from the complete list of SCHED_S_* codes in winerror.h. Not listed on purpose, so
# they alert: SCHED_S_TASK_TERMINATED (0x41306, the run was stopped, by a user or by
# the execution time limit, measured), SCHED_S_SOME_TRIGGERS_FAILED (0x4131B) and
# SCHED_S_BATCH_LOGON_PROBLEM (0x4131C, the task may fail to start). Every SCHED_E_*
# code and every exit code other than 0 is a failure as well.
RESULTS_OK = {
    0x00000000,  # the program exited with 0 (measured)
    0x00041300,  # SCHED_S_TASK_READY
    SCHED_S_TASK_RUNNING,  # (measured)
    0x00041302,  # SCHED_S_TASK_DISABLED
    SCHED_S_TASK_HAS_NOT_RUN,  # (measured)
    0x00041304,  # SCHED_S_TASK_NO_MORE_RUNS
    0x00041305,  # SCHED_S_TASK_NOT_SCHEDULED
    0x00041307,  # SCHED_S_TASK_NO_VALID_TRIGGERS
    0x00041308,  # SCHED_S_EVENT_TRIGGER
    0x00041325,  # SCHED_S_TASK_QUEUED
}


# What a failed last run means, in words, for the results an administrator meets in
# practice. The code follows in parentheses, so it can still be looked up.
RESULT_TEXTS = {
    # SCHED_S_TASK_TERMINATED, measured for all three causes on Windows Server 2025
    0x00041306: (
        'was stopped before it finished, by a user, by its execution time limit or'
        ' by a shutdown'
    ),
    # SCHED_S_SOME_TRIGGERS_FAILED and SCHED_S_BATCH_LOGON_PROBLEM, from winerror.h
    0x0004131B: 'ran, but some of its triggers failed',
    0x0004131C: (
        'may fail to start, its account lacks the right to log on as a batch job'
    ),
    # DBG_TERMINATE_PROCESS, measured for a task that runs in a user session
    # (\Microsoft\Windows\Wininet\CacheTask) after the host was shut down
    0x40010004: 'was ended by Windows, still running at logoff or shutdown',
    # SCHED_E_ALREADY_RUNNING, from winerror.h
    0x8004131F: 'did not start, an instance of the task was still running',
    # ERROR_FILE_NOT_FOUND as HRESULT, measured
    0x80070002: 'could not start its program, the file does not exist',
    # E_ACCESSDENIED
    0x80070005: 'was denied access',
    # ERROR_REQUEST_REFUSED, measured on built-in tasks of Windows Server 2025 whose
    # conditions (idle time, for example) were not met
    0x800710E0: 'was refused by the Task Scheduler',
}


def describe_result(code):
    """Put the result of a failed last run into words. `code` is unsigned."""
    if code in RESULT_TEXTS:
        return RESULT_TEXTS[code]
    # a small positive number is the exit code of the program the task started
    if code <= 0xFFFF:
        return f'ended with exit code {code}'
    # an HRESULT of the Win32 facility wraps a Windows error code
    if code & 0xFFFF0000 == 0x80070000:
        return f'failed with Windows error {code & 0xFFFF}'
    return 'failed'


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(
        '--extend-status',
        help='Expected task state, '
        'appended to the default or the `--status` list. '
        'Can be specified multiple times. '
        'Example: `--extend-status=Queued`.',
        dest='EXTEND_STATUS',
        action='append',
        choices=sorted(TASK_STATES.values()),
        default=None,
    )

    parser.add_argument(
        '--severity',
        help='Severity when the task is not in an expected state or its last run '
        'failed. '
        'Default: %(default)s',
        dest='SEVERITY',
        default=DEFAULT_SEVERITY,
        choices=['warn', 'crit'],
    )

    parser.add_argument(
        '--status',
        help='Expected task state. '
        'Replaces the default list, use `--extend-status` to append to it. '
        'Can be specified multiple times. '
        f'Default: {", ".join(DEFAULT_STATUS)}.',
        dest='STATUS',
        default=None,
        action='append',
        choices=sorted(TASK_STATES.values()),
    )

    parser.add_argument(
        '--task',
        help='Path of the Windows scheduled task to check, its folder followed by its '
        'name, as the Task Scheduler shows it. '
        'Case-insensitive. '
        r'Example: `--task=\Microsoft\Windows\Defrag\ScheduledDefrag`.',
        dest='TASK',
        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,
    )

    args, _ = parser.parse_known_args()
    return args


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)

    if args.TEST is None and not lib.base.WINDOWS:
        lib.base.cu('This check runs on Windows only.')

    # set default values for append parameters that were not specified
    if args.EXTEND_STATUS is None:
        args.EXTEND_STATUS = []
    if args.STATUS is None:
        args.STATUS = DEFAULT_STATUS
    # build a new list, `+=` would modify DEFAULT_STATUS in place
    args.STATUS = args.STATUS + args.EXTEND_STATUS

    # fetch data
    if args.TEST is None:
        result = lib.powershell.run_ps(SCRIPT, timeout=args.TIMEOUT)
        if result['retc'] != 0 and result['stderr'].startswith('Timeout after'):
            lib.base.oao(
                f'Timeout after {args.TIMEOUT}s while listing the scheduled tasks.',
                STATE_WARN,
                always_ok=args.ALWAYS_OK,
            )
        if result['retc'] != 0:
            lib.base.cu(result['stderr'])
        stdout = result['stdout']
    else:
        # do not call PowerShell, put in test data
        stdout, _, _ = lib.lftest.test(args.TEST)
    try:
        tasks = json.loads(stdout)
    except ValueError:
        lib.base.cu(
            f'Unable to read the list of scheduled tasks:\n\n{stdout}', traceback=False
        )

    # init some vars
    problem_state = STATE_CRIT if args.SEVERITY == 'crit' else STATE_WARN
    state = STATE_OK
    # Task paths are case-insensitive, and the leading backslash of the root folder is
    # easily left out.
    wanted = '\\' + args.TASK.lstrip('\\')

    # analyze data
    task = next(
        (item for item in tasks if item['path'].casefold() == wanted.casefold()),
        None,
    )
    if task is None:
        lib.base.cu(
            f'Scheduled task `{args.TASK}` not found. The account the check runs as'
            ' may not be allowed to see it.',
            traceback=False,
        )
    task_state = TASK_STATES.get(task['state'], 'Unknown')
    # the COM object hands out a signed 32-bit value, 0x80070002 arrives as negative
    last_result = task['last_result'] & 0xFFFFFFFF

    # build the message
    msg = f'{task["path"]} is {task_state}'
    if task_state not in args.STATUS:
        state = problem_state
        msg += (
            f' {lib.base.state2str(problem_state)}, but supposed to be'
            f' {" or ".join(args.STATUS)}'
        )
    # A task that is off on purpose keeps the result of its last run from before;
    # alerting on that would repeat an old story.
    if task_state != 'Disabled':
        if last_result == SCHED_S_TASK_HAS_NOT_RUN:
            msg += ', has not run yet'
        elif last_result == SCHED_S_TASK_RUNNING:
            msg += f', running since {task["last_run"]}'
        elif last_result in RESULTS_OK:
            msg += f', last run {task["last_run"]} succeeded'
        else:
            state = problem_state
            msg += (
                f', last run {task["last_run"]} {describe_result(last_result)}'
                f' (0x{last_result:X}){lib.base.state2str(problem_state, prefix=" ")}'
            )

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


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