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

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

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

DESCRIPTION = """Checks for available RPM package updates on RHEL, CentOS, Fedora, and compatible
systems. Reports the number and type of available advisories (bugfix, enhancement,
security). Alerts when the number of pending updates reaches the warning threshold, and
when the number of security updates reaches the critical threshold. This check only lists
updates and never actually installs anything."""

DEFAULT_CRIT = None  # number of security updates, off by default
DEFAULT_GRACE_SECURITY = '0D'
DEFAULT_GRACE_UPDATES = '0D'
DEFAULT_QUERY = '1'
DEFAULT_TIMEOUT = 120
DEFAULT_WARN = 1  # number of updatable packages


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='Minimum number of pending security updates to trigger a CRITICAL. '
        'Counts the updates carrying a security advisory, within the scope of '
        '`--query`. '
        'Unset by default, so security updates raise a WARNING like any other '
        'update until a threshold is given. '
        'Example: `--critical=1` '
        'Default: %(default)s',
        dest='CRIT',
        type=int,
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--grace-security',
        help=lib.args.help('--grace-security') + ' Default: %(default)s',
        dest='GRACE_SECURITY',
        type=lib.args.duration,
        default=DEFAULT_GRACE_SECURITY,
    )

    parser.add_argument(
        '--grace-updates',
        help=lib.args.help('--grace-updates') + ' Default: %(default)s',
        dest='GRACE_UPDATES',
        type=lib.args.duration,
        default=DEFAULT_GRACE_UPDATES,
    )

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

    parser.add_argument(
        '--only-critical',
        help='Only report security updates and upgrades.',
        dest='ONLY_CRITICAL',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--query',
        help='SQL WHERE clause to filter the list of available updates. '
        'Supports regular expressions via a REGEXP statement. '
        'See the README for a list of available columns. '
        'If specified, a list of matching updates is printed. '
        'Example: `--query=\'package like "bind9-%%"\'`. '
        'Default: %(default)s',
        dest='QUERY',
        default=DEFAULT_QUERY,
    )

    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='Minimum number of pending updates to trigger a WARNING. '
        'Default: %(default)s',
        dest='WARN',
        type=int,
        default=DEFAULT_WARN,
    )

    args, _ = parser.parse_known_args()
    return args


def get_updates(args):
    """
    Retrieve Available YUM Updates and Installed Packages

    This function executes two YUM commands to obtain:
    1. A list of available package updates (`yum list --upgrades`).
    2. A list of currently installed packages (`yum list --installed`).

    It uses the “Continue or Exit (CoE)” helper to run each shell command, ensuring that
    any error from `lib.shell.shell_exec` is properly handled. If there are no available
    updates, it reports this and does not exit. Any errors encountered when listing installed
    packages are logged but do not abort execution.

    Parameters
    ----------
    args : object
        An object (e.g., namespace) that must include:

        - `args.TIMEOUT` (`int`): Timeout in seconds for each shell command.

    Returns
    -------
    tuple
        - `yum_upgrades` (`str` or similar`): The stdout result of `"yum list --assumeyes --upgrades"`
        - `yum_installed` (`str` or similar`): The stdout result of `"yum list --installed"`

    Notes
    -----
    - The function assumes that `lib.base.coe` will exit the program on a shell command failure.
    - Any errors from listing installed packages are logged but do not stop execution.
    - If there are no updates, the function notifies the user and still returns a pair of values;
      in that case, `yum_upgrades` will be empty or falsy.
    - Intended for use within a script or plugin’s `main()` function.
    """
    # get the list of updates (--assumeyes so command can import GPG keys)
    if args.TEST is None:
        yum_upgrades, stderr, retc = lib.base.coe(
            lib.shell.shell_exec(
                ['yum', 'list', '--assumeyes', '--upgrades'], timeout=args.TIMEOUT
            ),
        )
    else:
        yum_upgrades, stderr, retc = lib.lftest.test(
            [args.TEST[0] + '-upgrades', args.TEST[1], args.TEST[2]],
        )
    if retc:
        lib.base.cu(f'`yum list --upgrades` returned with error {retc}: {stderr}')
    if not yum_upgrades:
        # no updates available
        lib.base.oao('No updates available.')

    # get the list of installed software
    if args.TEST is None:
        yum_installed, stderr, retc = lib.base.coe(
            lib.shell.shell_exec(['yum', 'list', '--installed'], timeout=args.TIMEOUT),
        )
    else:
        yum_installed, stderr, retc = lib.lftest.test(
            [args.TEST[0] + '-installed', args.TEST[1], args.TEST[2]],
        )
    if retc or stderr:
        lib.base.cu(f'`yum list --installed` returned with error {retc}: {stderr}')

    return yum_upgrades, yum_installed


def get_updateinfo(args):
    """
    Retrieve Available YUM Update Advisories

    This function executes the `yum updateinfo list --available` command to obtain
    advisory information about available package updates. It uses the “Continue or Exit (CoE)”
    helper to run the shell command, ensuring that any critical failure aborts execution.
    If the command returns a nonzero exit code, the error is logged but the function still
    returns whatever output (if any) was produced.

    Parameters
    ----------
    args : object
        An object (e.g., namespace) that must include:

        - `args.TIMEOUT` (`int`): Timeout in seconds for the shell command.

    Returns
    -------
    str
        The stdout result of `"yum updateinfo list --available"` containing update
        advisory information. This may be empty if no update advisories are available or
        if an error occurred.

    Notes
    -----
    - The function wraps `lib.shell.shell_exec(['yum', 'updateinfo', 'list', '--available'])`
      with `lib.base.coe()`. If the shell command itself fails (e.g., cannot run YUM), `coe` will
      exit the script after printing a sanitized error.
    - After unwrapping with `coe`, the function destructures into `(yum_info, stderr, retc)`.
      - If `retc` is nonzero (indicating a YUM-level error), it logs the error via `lib.base.cu()`
        but does **not** exit.
    - Advisory output formats differ by distribution:
      - **RHEL 8/9** example:

        .. code-block:: text

            RLSA-2024:1751              Important/Sec. unbound-libs-1.16.2-5.el8_9.6.x86_64
            RLBA-2024:1606              bugfix         util-linux-2.32.1-44.el8_9.1.x86_64

      - **Fedora** example:

        .. code-block:: text

            Name               Type     Severity Package                             Issued
            FEDORA-2025-09f40d bugfix   Low      python3-boto3-1.38.23-1.fc42.noarch 2025-05-30 01:14:13
            FEDORA-2025-34e9b9 security Critical firefox-139.0-1.fc42.x86_64         2025-05-30 02:21:33
    """
    # Get update information by using `yum updateinfo`.
    # RHEL8/9:
    #   RLSA-2024:1751              Important/Sec. unbound-libs-1.16.2-5.el8_9.6.x86_64
    #   RLBA-2024:1606              bugfix         util-linux-2.32.1-44.el8_9.1.x86_64
    # Fedora:
    #   Name               Type     Severity Package                             Issued
    #   FEDORA-2025-09f40d bugfix   Low      python3-boto3-1.38.23-1.fc42.noarch 2025-05-30 01:14:13
    #   FEDORA-2025-34e9b9 security Critical firefox-139.0-1.fc42.x86_64         2025-05-30 02:21:33
    if args.TEST is None:
        yum_info, stderr, retc = lib.base.coe(
            lib.shell.shell_exec(
                ['yum', 'updateinfo', 'list', '--available'], timeout=args.TIMEOUT
            ),
        )
    else:
        yum_info, stderr, retc = lib.lftest.test(
            [args.TEST[0] + '-updateinfo', args.TEST[1], args.TEST[2]],
        )
    if retc:
        lib.base.cu(
            f'`yum updateinfo list --available` returned with error {retc}: {stderr}'
        )

    return yum_info


def join_packages_updates(yum_installed, yum_upgrades):
    """
    Merge Installed and Available Upgrade Package Data

    This function takes the raw output strings from `yum list --installed` and
    `yum list --upgrades`, then combines them into a single dictionary of package entries.
    Each entry is keyed by the base package name (without architecture suffix) and contains
    details about the installed version, repository, and—if applicable—the available upgrade
    version and its repository.

    Parameters
    ----------
    yum_installed : str
        Multiline string output from
        `yum list --installed`. Each non-header line is expected to have exactly three
        whitespace-separated fields:
          1. `package_installed` (e.g., `"bash-5.0.17-1.fc32.x86_64"`)
          2. `version_installed` (e.g., `"5.0.17-1.fc32"`)
          3. `repo_installed` (e.g., `"@anaconda"`)
        Lines that do not split into exactly three fields are skipped.
    yum_upgrades : str
        Multiline string output from
        `yum list --upgrades`. Each non-header line is expected to have exactly three
        whitespace-separated fields:
          1. `package_upgrade` (e.g., `"bash-5.0.17-2.fc32.x86_64"`)
          2. `version_upgrade` (e.g., `"5.0.17-2.fc32"`)
          3. `repo_upgrade` (e.g., `"updates"`)
        Lines that do not split into exactly three fields are skipped.

    Returns
    -------
    dict[str, dict]
        A dictionary mapping each base package name
        (the portion before the first `.` in the RPM filename) to a sub-dictionary with keys:

          - **"package"** (`str`): Base package name (e.g., `"bash"`).
          - **"arch"** (`str`): Architecture suffix extracted from the RPM filename
            (e.g., `"x86_64"`).
          - **"version_installed"** (`str`): Installed version and release exactly as
            reported (e.g., `"5.0.17-1.fc32"`, `"2.9.7-21.el8_10.6"`).
          - **"repo_installed"** (`str`): Repository from which the package was installed
            (e.g., `"@anaconda"`).
          - **"version_upgrade"** (`str` or `None`): Available upgrade version (same format
            as `version_installed`), or `None` if no upgrade is listed.
          - **"repo_upgrade"** (`str` or `None`): Repository from which the upgrade would come,
            or `None` if no upgrade is listed.

    Notes
    -----
    - Package name splitting logic assumes RPM filenames formatted as
      `<name>-<version>.<release>.<arch>`. If a package name contains additional dots or
      unexpected formatting, the splitting heuristic may misidentify the base name.
    - Lines in the YUM output that are headers, separators, or otherwise do not have exactly
      three whitespace-separated columns are silently ignored.
    - If there is an upgrade entry for a package not present in `yum_installed`, a `KeyError`
      will occur. Should never happen...
    """
    packages = {}

    for installed in yum_installed.strip().splitlines():
        installed = installed.split()
        if len(installed) != 3:
            continue
        package_installed, version_installed, repo_installed = installed
        packages[package_installed.split('.')[0]] = {
            'package': package_installed.split('.')[0],
            'arch': package_installed.split('.')[-1],
            # Version and release are kept exactly as yum reports them. Dropping
            # the last dotted segment shortens `2.56.4-170.el8_10` nicely, but a
            # z-stream release carries its counter behind the dist tag, so the
            # same rule turned `2.9.7-21.el8_10.6` and `2.9.7-21.el8_10.7` into
            # one and the same string and the table showed an update to the
            # version already installed.
            'version_installed': version_installed,
            'repo_installed': repo_installed,
            'version_upgrade': None,
            'repo_upgrade': None,
        }

    for upgrade in yum_upgrades.strip().splitlines():
        upgrade = upgrade.split()
        if len(upgrade) != 3:
            continue
        package_upgrade, version_upgrade, repo_upgrade = upgrade
        packages[package_upgrade.split('.')[0]].update(
            {
                'version_upgrade': version_upgrade,
                'repo_upgrade': repo_upgrade,
            }
        )

    return packages


def store_updateinfo(conn, yum_info):
    """
    Parse and Store YUM Update Advisories into SQLite

    This function processes the raw advisory output from `yum updateinfo list --available`
    (or similar) and inserts parsed entries into an SQLite table named `updateinfo`. It
    handles both RHEL-style and Fedora-style advisory lines, extracting the package NEVRA
    (Name-Epoch-Version-Release-Architecture) components via a regular expression, then
    classifies each advisory by type and severity before inserting into the database.

    Parameters
    ----------
    conn : sqlite3.Connection or similar
        An open SQLite connection object. Entries will be inserted into the `updateinfo` table
        on this connection.
    yum_info : str
        Multiline string containing advisory entries from `yum updateinfo list --available`.

        - **RHEL 8/9 format** (3 fields when split):

          .. code-block:: text

              RLSA-2025:0288  Moderate/Sec.  emacs-filesystem-1:27.2-10.el9_4.noarch
              RLSA-2025:0288  bugfix         libstdc++-devel-11.4.1-3.el9.x86_64
        - **Fedora format** (6 fields when split):

          .. code-block:: text

              RLSA-2025:0288  bugfix  Low  python3-s3transfer-0.13.0-2.fc42.noarch  2025-05-30  01:14:13

    Returns
    -------
    None
        This function does not return a value. Successful inserts happen silently; on
        failure, the script exits.

    Notes
    -----
    - The function assumes:
      - A table named `updateinfo` already exists with at least the columns `name`, `version`,
       `type`, and `severity`.
      - The SQLite connection `conn` is open and writable.
    - If an advisory line contains a package not matching the NEVRA pattern, it will be skipped.
    - Fedora-style lines include timestamp fields that are ignored by this function.
    - The column `platform` and `arch` extracted by the regex are not stored in the database; only
     `name` and `version` matter.
    - If you need to store additional metadata (e.g., architecture or advisory ID), modify the
      insert dictionary accordingly.
    """
    # Matches RPM NEVRA package names from updateinfo (for example: "netavark-2:1.15-1.fc42.x86_64")
    pattern = re.compile(
        r"""^
          (?P<name>.+)             # Package name: everything up to the final "-" before the version
          -
          (?P<version>[^-]+-[^-]+) # Version-Release (e.g. "2:1.40.16-19" or "4.18.0-553.37.1")
          \.
          (?P<platform>.+)         # Platform: everything between last "." before <arch> ("el8_10")
          \.
          (?P<arch>[^.]+)          # Architecture: everything til the end of the str (e.g. "x86_64")
        $""",
        re.VERBOSE,
    )

    for item in yum_info.strip().splitlines():
        item = item.split()
        if len(item) == 3:
            # 'RLSA-2025:0288', 'Moderate/Sec.', 'emacs-filesystem-1:27.2-10.el9_4.noarch'
            # 'RLSA-2025:0288', 'bugfix', 'libstdc++-devel-11.4.1-3.el9.x86_64'
            m = pattern.match(item[2])
            if not m:
                continue
            lib.base.coe(
                lib.db_sqlite.insert(
                    conn,
                    {
                        'name': m.group('name'),
                        'version': m.group('version'),
                        'type': 'security' if item[1].endswith('/Sec.') else item[1],
                        'severity': item[1].split('/')[0]
                        if item[1].endswith('/Sec.')
                        else 'None',
                    },
                    table='updateinfo',
                )
            )
        if len(item) == 6:
            # ('RLSA-2025:0288', 'bugfix', 'Low', 'python3-s3transfer-0.13.0-2.fc42.noarch', '2025-05-30', '01:14:13')
            m = pattern.match(item[3])
            if not m:
                continue
            lib.base.coe(
                lib.db_sqlite.insert(
                    conn,
                    {
                        'name': m.group('name'),
                        'version': m.group('version'),
                        'type': item[1],
                        'severity': item[2],
                    },
                    table='updateinfo',
                )
            )


def get_advisory_type(update_data, info_data):
    """
    Annotate Updates with Advisory Types

    This function iterates over a list of update entries (`update_data`) and a list of
    advisory info entries (`info_data`), matching them by package name. For each update,
    it aggregates advisory “type” codes (taking the first character of each matching
    advisory’s type and uppercasing it) into a concatenated string under the key `'type'`.
    Matched advisory entries are removed from `info_data` as they are consumed.

    Parameters
    ----------
    update_data : list[dict]
        A list of dictionaries, each representing an available update. Each dictionary must contain
        at least:

        - `'package'` (`str`): The base package name to match against advisory info.
        Additional keys may be present (e.g., version, repository), but are not required for matching.
        The function will inject a new key `'type'` into each dictionary to store the aggregated
        advisory codes.
    info_data : list[dict]
        A list of advisory information dictionaries. Each dictionary must contain:

        - `'name'` (`str`): Package name, used to match against `update['package']`.
        - `'type'` (`str` or similar): Advisory type string (e.g., `"security"`, `"bugfix"`).
          Only the first character of this string (uppercased) is used.
        As matches are found, corresponding dictionaries are removed from `info_data`.

    Returns
    -------
    list[dict]
        The list of update dictionaries (from `update_data`). Each dictionary has an added key:

        - `'type'` (`str`): Concatenated uppercase characters, one per matching advisory.
          For example, if two advisories matched—one `"security"` and one `"bugfix"`—then
          `'type' == "SB"`.

    Notes
    -----
    - The function mutates both `update_data` (by injecting `'type'` keys) and `info_data`
      (by removing matched entries).
      If the caller needs to preserve the original lists, they should pass deep copies.
    - If an update has no matching entries in `info_data`, its `'type'` remains an empty string.
    - The ordering of characters in `'type'` corresponds to the order in which matches were found;
      duplicates may occur if multiple advisories share the same first letter.
    - The `lookup_lod` helper is expected to return a tuple `(index, info_dict)`.
      If multiple advisory entries share the same `'name'`, all will be consumed until none remain.
    """
    table_data = []
    for update in update_data:
        update.update({'type': ''})
        while True:
            iidx, info = lib.base.lookup_lod(info_data, 'name', update['package'])
            if iidx == -1:
                break
            info_data.pop(iidx)
            update['type'] += info['type'][0].upper()
        table_data.append(update)

    return table_data


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)

    # Pad args.TEST to 3 elements so the `get_updates()` /
    # `get_updateinfo()` helpers below that splice `args.TEST[1]`
    # and `args.TEST[2]` into custom lib.lftest.test() calls do not
    # go out of range when the user passes `--test=path` without
    # trailing commas.
    if args.TEST is not None:
        while len(args.TEST) < 3:
            args.TEST.append('')

    # fetch data

    # get the list of installed software
    yum_upgrades, yum_installed = get_updates(args)

    # join the list of installed packages and their updates
    packages = join_packages_updates(yum_installed, yum_upgrades)

    # Remember since when an update is pending for each package, so the grace
    # periods below can hold an alert back until the host has had a patch window.
    # Ages are kept for every pending package, not for the subset `--query`
    # selects, so two services with different queries do not forget each other's
    # packages. Skipped under `--test`, which must not touch the host's state.
    ages = None
    if args.TEST is None:
        ages = lib.db_sqlite.first_seen(
            'linuxfabrik-monitoring-plugins-rpm-updates.db',
            'rpm-updates',
            [pkg['package'] for pkg in packages.values() if pkg['version_upgrade']],
        )

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

    # analyze data

    # The database is private to this process: it holds nothing worth keeping
    # between runs, and a file under a fixed name is shared with every concurrent
    # run of this check, which then count each other's rows.
    conn = lib.base.coe(lib.db_sqlite.connect(in_memory=True))

    # create the db table for the installed packages including their updates
    sql = """
        package TEXT PRIMARY KEY,
        arch TEXT DEFAULT NULL,
        version_installed TEXT DEFAULT NULL,
        repo_installed TEXT DEFAULT NULL,
        version_upgrade TEXT DEFAULT NULL,
        repo_upgrade TEXT DEFAULT NULL
    """
    lib.base.coe(
        lib.db_sqlite.create_table(
            conn,
            sql,
            table='list',
        )
    )

    # ready for the database
    for package in packages.values():
        if not package['version_upgrade']:
            # no need to store packages without updates
            continue
        lib.base.coe(
            lib.db_sqlite.insert(
                conn,
                package,
                table='list',
            )
        )

    # Get update information by using `yum updateinfo`.
    yum_info = get_updateinfo(args)

    # create the db table for the update info
    sql = """
        name TEXT DEFAULT NULL,
        version TEXT DEFAULT NULL,
        type TEXT DEFAULT NULL,
        severity TEXT DEFAULT NULL
    """
    lib.base.coe(
        lib.db_sqlite.create_table(
            conn,
            sql,
            table='updateinfo',
        )
    )
    # "package" can't be a PRIMARY KEY (it will not be UNIQUE), but an index will speed things up
    lib.base.coe(lib.db_sqlite.create_index(conn, 'name', table='updateinfo'))

    # store updateinfo
    store_updateinfo(conn, yum_info)

    # store data in local sqlite database
    lib.base.coe(lib.db_sqlite.commit(conn))

    # fetch desired objects only, and set sqlite3 to be case insensitive when string comparing
    # QUERY is by design an admin-provided SQL WHERE clause (documented feature)
    sql = f"""
        SELECT *
        FROM list
        WHERE {args.QUERY}
        ORDER BY package
        COLLATE NOCASE
    """  # nosec B608
    update_data = lib.base.coe(lib.db_sqlite.select(conn, sql))

    sql = f"""
        SELECT DISTINCT *
        FROM list LEFT JOIN updateinfo ON package = name
        WHERE {args.QUERY}
        ORDER BY package
        COLLATE NOCASE
    """  # nosec B608
    info_data = lib.base.coe(lib.db_sqlite.select(conn, sql))
    if not info_data:
        # happens when we search for security updates, but first query returns standard updates only
        update_data = []

    lib.db_sqlite.close(conn)

    # for each package get the update types up to the newest available version (first letter)
    table_data = get_advisory_type(update_data, info_data)

    # `--only-critical` narrows down what is reported, while the security count
    # itself stays available either way, so both numbers can be trended at once
    critical_count = sum(1 for row in table_data if 'S' in row['type'])
    if args.ONLY_CRITICAL:
        table_data = [row for row in table_data if 'S' in row['type']]

    # Only updates that outlived their grace period drive the state. An age we
    # could not read counts as old enough, so a cache that went missing never
    # silences the check. The reported and graphed numbers stay untouched: what
    # is pending is pending, the grace period only postpones the alert.
    def grace_of(row):
        # A security advisory is held back by `--grace-security`, everything else
        # by `--grace-updates`. Both counts share this rule, so the default
        # `--grace-security=0D` keeps security updates alerting right away even
        # when `--critical` is unset and they are only counted as ordinary updates.
        return args.GRACE_SECURITY if 'S' in row['type'] else args.GRACE_UPDATES

    def is_due(row):
        if ages is None:
            return True
        return ages.get(row['package'], 0) >= grace_of(row)

    due_count = sum(1 for row in table_data if is_due(row))
    due_critical_count = sum(
        1 for row in table_data if 'S' in row['type'] and is_due(row)
    )
    within_grace = len(table_data) - due_count

    # get state
    updates_state = lib.base.get_state(due_count, args.WARN, None)
    critical_state = lib.base.get_state(due_critical_count, None, args.CRIT)
    state = lib.base.get_worst(updates_state, critical_state)

    # The sentence above the table only carries totals, and the number that
    # drives the state is the number of due updates, not the number available.
    # Naming per row the state it feeds traces the check's own state back to
    # the packages responsible for it, and says of the rest when they follow.
    # Both thresholds count packages, so no single row crosses one on its own:
    # every due row carries the state its count produced. A row that alerts
    # nobody carries no marker rather than an `[OK]`, which next to a pending
    # update looks like there is nothing left to do; it still says `overdue`,
    # so every row that has run out its grace period reads the same way.
    for row in table_data:
        if not is_due(row):
            left = grace_of(row) - ages.get(row['package'], 0)
            row['state'] = f'due in {lib.human.seconds2human(left)}'
            continue
        row_state = updates_state
        if 'S' in row['type']:
            row_state = lib.base.get_worst(row_state, critical_state)
        row['state'] = f'overdue{lib.base.state2str(row_state, prefix=" ")}'

    # build the message
    if len(table_data) == 0:
        msg += f'No updates available{" (query: " + args.QUERY + ")" if args.QUERY != "1" else ""}.'
    else:
        msg += f'{len(table_data)} {"critical " if args.ONLY_CRITICAL else ""}'
        msg += f'{lib.txt.pluralize("update", len(table_data))} available'
        if critical_count and not args.ONLY_CRITICAL:
            msg += f', {critical_count} of them critical'
        msg += f'{" (query: " + args.QUERY + ")" if args.QUERY != "1" else ""}'
        msg += '.'
        if within_grace:
            # Naming the periods answers the question the sentence otherwise
            # provokes: an admin who reads that something is being held back wants
            # to know for how long, and the two are not always the same.
            msg += (
                f' {within_grace} of them within the grace period'
                f' (updates: {args.GRACE_UPDATES}, security: {args.GRACE_SECURITY}).'
            )
        msg += lib.base.state2str(state, prefix=' ')
        msg += '\n\n'
        msg += lib.base.get_table(
            table_data,
            ['package', 'version_installed', 'version_upgrade', 'type', 'state'],
            header=['Package', 'Installed', 'Upgrade to', 'Type', 'State'],
        )
    perfdata += lib.base.get_perfdata(
        'updates',
        len(table_data),
        uom=None,
        warn=args.WARN,
        _min=0,
    )
    perfdata += lib.base.get_perfdata(
        'critical_updates',
        critical_count,
        uom=None,
        crit=args.CRIT,
        _min=0,
    )

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