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

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

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

DESCRIPTION = """Checks for outdated Python packages installed via pip. Reports the number of packages
with available updates and lists them. Alerts when the count exceeds the configured
threshold."""

DEFAULT_WARN = 10
DEFAULT_CRIT = 100
DEFAULT_TIMEOUT = 50  # the command timeout of the basket is 60


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(
        '-c',
        '--critical',
        help='CRIT threshold for the number of outdated packages. Default: %(default)s',
        dest='CRIT',
        default=DEFAULT_CRIT,
        type=lib.args.int_or_none,
    )

    parser.add_argument(
        '--exclude',
        help='Package name to exclude from the output. '
        'Can be specified multiple times.',
        dest='EXCLUDE',
        action='append',
    )

    parser.add_argument(
        '--extra-index-url',
        help='Extra URL of a package index to use in addition to --index-url. '
        'Should follow the same rules as --index-url. '
        'Can be specified multiple times.',
        dest='EXTRA_INDEX_URL',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--find-links',
        help='URL or path to an HTML file to parse for links to archives (.tar.gz, .whl). '
        'If a local path or file:// URL pointing to a directory, look for archives in the directory listing. '
        'VCS project URLs are not supported. '
        'Can be specified multiple times.',
        dest='FIND_LINKS',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--index-url',
        help='Base URL of the Python Package Index (PEP 503 compliant repository or local directory in the same format).',
        dest='INDEX_URL',
    )

    parser.add_argument(
        '--local',
        help='If in a virtualenv that has global access, do not list globally-installed packages.',
        dest='LOCAL',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--no-index',
        help='Ignore the package index and only look at --find-links URLs.',
        dest='NO_INDEX',
        action='store_true',
        default=False,
    )

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

    parser.add_argument(
        '--not-required',
        help='Only list packages that are not dependencies of other installed packages.',
        dest='NOT_REQUIRED',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--pre',
        help='Include pre-release and development versions. '
        'By default, pip only finds stable versions.',
        dest='PRE',
        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(
        '--user',
        help='Only check packages installed in the user-site directory.',
        dest='USER',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--virtualenv',
        help='Path to the activate script of a virtualenv. The interpreter of that '
        'virtualenv is used to check for updates inside it. '
        'Example: `--virtualenv=/opt/sphinx-venv/bin/activate`.',
        dest='VIRTUALENV',
    )

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for the number of outdated packages. Default: %(default)s',
        dest='WARN',
        default=DEFAULT_WARN,
        type=lib.args.int_or_none,
    )

    args, _ = parser.parse_known_args()
    return args


def get_interpreter(virtualenv):
    """Return the `python3` next to the activate script of a virtualenv.

    Returns `(True, path)`, or `(False, message)` if `virtualenv` is no activate script
    or no interpreter lies next to it.
    """
    if os.path.basename(virtualenv) != 'activate' or not os.path.isfile(virtualenv):
        return (
            False,
            f'--virtualenv expects the activate script of a virtualenv, for example '
            f'`/opt/sphinx-venv/bin/activate`, and `{virtualenv}` is none.',
        )
    python = os.path.join(os.path.dirname(virtualenv), 'python3')
    if not os.path.isfile(python):
        return (False, f'No interpreter `{python}` next to `{virtualenv}`.')
    return (True, python)


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.EXTRA_INDEX_URL is None:
        args.EXTRA_INDEX_URL = []
    if args.FIND_LINKS is None:
        args.FIND_LINKS = []

    # fetch data
    cmd = ['python3', '-m', 'pip', 'list', '--outdated', '--format=json']
    if args.TEST is None:
        # build commandline call
        for item in args.EXCLUDE or []:
            cmd.append(f'--exclude={item}')
        for item in args.EXTRA_INDEX_URL or []:
            cmd.append(f'--extra-index-url={item}')
        for item in args.FIND_LINKS or []:
            cmd.append(f'--find-links={item}')
        if args.INDEX_URL:
            cmd.append(f'--index-url={args.INDEX_URL}')
        if args.LOCAL:
            cmd.append('--local')
        if args.NO_INDEX:
            cmd.append('--no-index')
        if args.NOT_REQUIRED:
            cmd.append('--not-required')
        if args.PRE:
            cmd.append('--pre')
        if args.USER:
            cmd.append('--user')

        if args.VIRTUALENV:
            # Run the virtualenv's own interpreter instead of sourcing its activate
            # script, which avoids a shell.
            cmd[0] = lib.base.coe(get_interpreter(args.VIRTUALENV))
        else:
            # name the interpreter PATH resolves to, so the output says whose packages
            # were counted (an activated venv, or the system Python under sudo)
            cmd[0] = shutil.which(cmd[0]) or cmd[0]

        # pip asks the package index for every package
        success, result = lib.shell.shell_exec(cmd, timeout=args.TIMEOUT)
        if not success and result.startswith('Timeout after'):
            lib.base.oao(
                f'Timeout after {args.TIMEOUT}s while running `{" ".join(cmd)}`.',
                STATE_WARN,
                always_ok=args.ALWAYS_OK,
            )
        stdout, stderr, retc = lib.base.coe((success, result))
    else:
        # do not call the command, put in test data
        stdout, stderr, retc = lib.lftest.test(args.TEST)
    if retc != 0:
        if 'No module named pip' in stderr:
            # Distributions ship pip as a separate package, and the interpreter found
            # in PATH (sudo resets it to secure_path) is often not the one the admin
            # has in mind.
            lib.base.cu(
                f'pip is not installed for `{cmd[0]}`, so the outdated packages cannot '
                f'be listed. Install the pip package of the distribution '
                f'(`python3-pip`), or point --virtualenv at the venv to check.'
            )
        lib.base.cu(stderr)

    # init some vars
    msg = ''

    # analyze data
    try:
        packages = json.loads(stdout)
    except json.decoder.JSONDecodeError:
        lib.base.cu('Failed to parse JSON.')
    state = lib.base.get_state(len(packages), args.WARN, args.CRIT)

    # build the message
    perfdata = lib.base.get_perfdata(
        'pip_outdated_packages',
        len(packages),
        uom=None,
        warn=args.WARN,
        crit=args.CRIT,
        _min=0,
        _max=None,
    )
    if state == STATE_OK:
        msg += 'Everything is ok. '
    if packages:
        msg += (
            f'{len(packages)} outdated {lib.txt.pluralize("package", len(packages))}'
            f'{lib.base.state2str(state, prefix=" ")}. '
        )
    # what pip had to say first, for example "[notice] A new release of pip is available"
    pip_says = next((line.strip() for line in stderr.splitlines() if line.strip()), '')
    if pip_says:
        msg += f'pip says `{pip_says}`. '
    cmd_str = ' '.join(token for token in cmd if token != '--format=json')
    msg += f'Executed command: `{cmd_str}`'

    if packages:
        msg += '\n\n'
        keys = [
            'name',
            'version',
            'latest_version',
            'latest_filetype',
        ]
        headers = [
            'Package',
            'Version',
            'Latest',
            'Type',
        ]
        msg += lib.base.get_table(packages, keys, header=headers)

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