#!/usr/bin/env python3
# ***************************************************************************
#  IIIIII NNN  NNN  Copyright (C) 2026 Innovative Networks, Inc.
#    II    NNN  N   All Rights Reserved. Any redistribution or reproduction
#    II    N NN N   of part or all of the content of this program in any form
#    II    N  NNN   without expressed written consent of the copyright holder
#  IIIIII NNN  NNN  is strictly prohibited.  Please contact
#                   admins@indigex.com for additional information.
# ***************************************************************************
#  check_airfiber.py - Ubiquiti airFiber Link Check
#  Author - Myah Mitchell <mmitchell@indigex.com>
#
#  Purpose:  Checks a Ubiquiti airFiber radio link by logging in to the radio's
#            web interface and reading /status.cgi. Alerts on link state,
#            receive power, modulation, GPS, uptime and radio health, and
#            returns every value as performance data for graphing.
#
#  Note:     Only tested against the airFiber 24 (AF24) on firmware v4. Other
#            models may need modifications: models on newer firmware (e.g.
#            AF60, AF5XHD) may log in differently, and any status.cgi field
#            this script reads that a model doesn't report makes the check
#            return UNKNOWN. Run with -v against a new model to check it.
#
#  Version History:
#       2026.10.14  - First release. Rewritten for Python 3 from UBNT-AF24_http.py
#                     in ubnt-nagios-plugins by Zenon Mousmoulas
# ***************************************************************************
#
#  Requirements:
#    - Python 3.10 or later with the requests module
#    - inmon_utils.py and inmon_quant.py (Global/) on the Python path
#    - A login on the radio. A read-only account is enough.
#
#  Usage:
#    check_airfiber.py -H <hostname> -p <password> [-u <username>] [-s] [-P <port>]
#                  [-w <label>,<range>]... [-c <label>,<range>]... [-o <key>,<value>]...
#                  [-t <seconds>] [-v] [-V]
#
#    -H  Radio hostname or IPv4 address
#    -p  Radio password
#    -u  Radio username (default: ubnt)
#    -s  Use HTTPS instead of HTTP. The radio's self-signed certificate is accepted.
#    -P  Port, if the web interface is not on the default port for HTTP or HTTPS
#    -w  Warning threshold for one label. Repeat for each label.
#    -c  Critical threshold for one label. Repeat for each label.
#    -o  Expected value for a pass/fail check. Repeat for each check.
#    -t  Seconds before the check gives up and returns CRITICAL (default: 10)
#    -v  More output: -v lists every check, -vv logs each request, -vvv logs the raw status.cgi data
#    -V  Show the version and exit
#
#  Examples:
#    Built-in thresholds and checks only:
#      check_airfiber.py -H 192.0.2.10 -p secret
#    HTTPS, warn when chain 0 drops below -75 dBm and go critical below -80 dBm:
#      check_airfiber.py -H radio.example.com -s -p secret -w airfiber.rxpower0,-75: -c airfiber.rxpower0,-80:
#    A link on a 100 Mbps switch port, with the uptime warning turned off:
#      check_airfiber.py -H 192.0.2.10 -p secret -o airfiber.data_speed,100Mbps-Full -w host.uptime,
#
#  Thresholds (-w and -c):
#    Given as <label>,<range>, using the standard [@]start:end range format.
#    A value outside the range alerts; endpoints are inside the range.
#      10       alert below 0 or above 10
#      10:      alert below 10
#      ~:10     alert above 10 (~ is negative infinity)
#      -80:-60  alert below -80 or above -60
#      @10:20   alert from 10 to 20 (@ alerts inside the range instead)
#    An empty range, e.g. "-w host.uptime,", turns off a built-in threshold.
#
#  Labels and built-in thresholds:
#    airfiber.rxpower0_below_ideal         warn ~:12  crit ~:18  dB that local chain 0 Rx is below
#    airfiber.rxpower1_below_ideal         warn ~:12  crit ~:18    the ideal for the link (the radio
#    airfiber.remote_rxpower0_below_ideal  warn ~:12  crit ~:18    works out the ideal from the far
#    airfiber.remote_rxpower1_below_ideal  warn ~:12  crit ~:18    end's Tx power and the distance)
#    airfiber.txmodrate                    warn 2:               Modulation, 1 to 6 (from 1x to 6x)
#    airfiber.remote_txmodrate             warn 2:
#    airfiber.linkuptime                   warn 3600:            Seconds since the link came up
#    host.uptime                           warn 3600:            Seconds since the radio booted
#    gps.sats                              warn 5:               GPS satellites in view
#    host.mem_used                         warn 80    crit 90    Memory used, %
#    host.loadavg                          warn 2     crit 4
#    No built-in thresholds: airfiber.rxpower0, airfiber.rxpower1, airfiber.remote_rxpower0,
#    airfiber.remote_rxpower1, airfiber.idealpwr, airfiber.remote_idealpwr, airfiber.txpower,
#    airfiber.remote_txpower, wireless.signal (all dBm), airfiber.rxcapacity, airfiber.txcapacity
#    (bits/s), airfiber.rxgain, airfiber.remote_rxgain (1 high, 0 low), wireless.distance (m),
#    gps.dop_quality (%, from the GPS dilution of precision)
#
#  Pass/fail checks (-o), CRITICAL when the value doesn't match:
#    airfiber.rxpower0valid, airfiber.rxpower1valid                1
#    airfiber.remote_rxpower0valid, airfiber.remote_rxpower1valid  1
#    airfiber.rxoverload0, airfiber.rxoverload1                    0
#    airfiber.remote_rxoverload0, airfiber.remote_rxoverload1      0
#    gps.status, gps.fix                                           1
#    airfiber.data_speed                                           1000Mbps-Full
#    airfiber.linkstate                                            operational
#    -o can change any of these, or add a check on any other status.cgi key,
#    e.g. "-o wireless.essid,site-a-b". Text values are graphed as 1 (pass) or 0 (fail).
#
#  Exit status: OK, WARNING or CRITICAL from the checks above; CRITICAL when the
#  radio doesn't answer within the timeout; UNKNOWN for login failures, invalid
#  arguments, and missing or unexpected status.cgi data.
# ***************************************************************************

# pylint: disable=broad-exception-caught
# pylint: disable=missing-module-docstring
# pylint: disable=invalid-name

from __future__ import annotations
import sys

try:
    import inmon_utils as inmon
    import inmon_quant as quant
except Exception as e:
    print(f"[UNKNOWN] - Failed to import inmon_utils: {e}")
    sys.exit(3)

import argparse
import logging
import math
import os
import signal

import requests
import urllib3

VERSION = "2026.10.14"

# ----- Argument parsing and validation -----


class ArgumentParser(argparse.ArgumentParser):
    """Exits UNKNOWN on invalid arguments. argparse exits 2, which Icinga reads as CRITICAL."""

    def error(self, message):
        print(f"[UNKNOWN] - {message}")
        sys.exit(3)


parser = ArgumentParser(
    description="Checks a Ubiquiti airFiber radio link over HTTP(S). Only tested against the AF24.",
    epilog="Ranges use the [@]start:end format, and an empty range turns off a built-in threshold. "
    "See the comments at the top of this script for all labels, built-in thresholds and examples.",
)
parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {VERSION}")
parser.add_argument(
    "-v",
    "--verbose",
    dest="verbose",
    action="count",
    default=0,
    help="More output, up to three times: -v lists every check, -vv logs each request, "
    "-vvv logs the raw status.cgi data",
)
parser.add_argument(
    "-H",
    "--hostname",
    dest="hostname",
    help="Radio hostname or IPv4 address",
    required=True,
)
parser.add_argument(
    "-P",
    "--port",
    dest="port",
    type=int,
    help="Web interface port (default: 80, or 443 with -s)",
)
parser.add_argument(
    "-s",
    "--https",
    dest="https",
    action="store_true",
    help="Connect with HTTPS. The radio's self-signed certificate is accepted.",
)
parser.add_argument(
    "-u",
    "--username",
    dest="username",
    help="Radio username (default: ubnt)",
    default="ubnt",
)
parser.add_argument(
    "-p", "--password", dest="password", help="Radio password", required=True
)
parser.add_argument(
    "-w",
    "--warning",
    dest="warning",
    type=inmon.csv,
    action="append",
    help="Warning threshold as <label>,<range>, e.g. airfiber.rxpower0,-75: (repeat for each label)",
)
parser.add_argument(
    "-c",
    "--critical",
    dest="critical",
    type=inmon.csv,
    action="append",
    help="Critical threshold as <label>,<range>, e.g. airfiber.rxpower0,-80: (repeat for each label)",
)
parser.add_argument(
    "-o",
    "--override",
    dest="override",
    type=inmon.csv,
    action="append",
    help="Expected value for a pass/fail check as <status.cgi key>,<value>, "
    "e.g. airfiber.data_speed,100Mbps-Full (repeat for each check)",
)
parser.add_argument(
    "-t",
    "--timeout",
    dest="timeout",
    type=int,
    help="Seconds before the check gives up and returns CRITICAL (default: 10)",
    default=10,
)
args = parser.parse_args()

# Lists are filled in here rather than as argparse defaults, which append would add to
args.warning = args.warning or []
args.critical = args.critical or []
args.override = args.override or []

if args.verbose > 3:
    parser.error("-v can be given at most three times")
if not (inmon.is_valid_hostname(args.hostname) or inmon.is_valid_ip(args.hostname)):
    parser.error(f"{args.hostname} is not a valid hostname or IPv4 address")
if args.port is not None and not 1 <= args.port <= 65535:
    parser.error("port must be from 1 to 65535")
if args.timeout < 1:
    parser.error("timeout must be at least 1 second")

# -vv logs each request, -vvv also logs the raw status.cgi data
logging.basicConfig(
    level={2: logging.INFO, 3: logging.DEBUG}.get(args.verbose, logging.WARNING)
)

# ----- Global variables -----

SCHEME = "https" if args.https else "http"
BASE_URL = f"{SCHEME}://{args.hostname}" + (f":{args.port}" if args.port else "")

STATUS_NAMES = ["OK", "WARNING", "CRITICAL", "UNKNOWN"]

# Icinga output lines should be kept to 80 characters
MAX_LINE_LENGTH = 80

# Values to check and graph: (label, status.cgi key, unit, min, max)
# Labels without a key are calculated in read_metric.
METRICS = [
    ("airfiber.rxpower0", "airfiber.rxpower0", "dBm", -100, 0),
    ("airfiber.rxpower1", "airfiber.rxpower1", "dBm", -100, 0),
    ("airfiber.rxcapacity", "airfiber.rxcapacity", None, 0, 750000000),
    ("airfiber.txcapacity", "airfiber.txcapacity", None, 0, 750000000),
    ("airfiber.txmodrate", "airfiber.txmodrate", None, 0, 6),
    ("wireless.distance", "wireless.distance", None, 100, 15000),
    ("gps.dop_quality", "gps.dop", "%", 0, 100),
    ("gps.sats", "gps.sats", None, 0, 10),
    ("airfiber.txpower", "airfiber.txpower", "dBm", None, None),
    ("airfiber.rxgain", "airfiber.rxgain", None, 0, 1),
    ("airfiber.remote_txpower", "airfiber.remote_txpower", "dBm", None, None),
    ("airfiber.remote_rxpower0", "airfiber.remote_rxpower0", "dBm", -100, 0),
    ("airfiber.remote_rxpower1", "airfiber.remote_rxpower1", "dBm", -100, 0),
    ("host.uptime", "host.uptime", "s", 0, None),
    ("airfiber.linkuptime", "airfiber.linkuptime", "s", 0, None),
    ("airfiber.remote_txmodrate", "airfiber.remote_txmodrate", None, 0, 6),
    ("airfiber.remote_rxgain", "airfiber.remote_rxgain", None, 0, 1),
    ("airfiber.idealpwr", "airfiber.idealpwr", "dBm", -100, 0),
    ("airfiber.remote_idealpwr", "airfiber.ridealpwr", "dBm", -100, 0),
    ("host.loadavg", "host.loadavg", None, 0, None),
    ("host.mem_used", None, "%", 0, 100),
    ("wireless.signal", "wireless.signal", "dBm", -100, 0),
    ("airfiber.rxpower0_below_ideal", None, None, None, None),
    ("airfiber.rxpower1_below_ideal", None, None, None, None),
    ("airfiber.remote_rxpower0_below_ideal", None, None, None, None),
    ("airfiber.remote_rxpower1_below_ideal", None, None, None, None),
]
METRIC_LABELS = [metric[0] for metric in METRICS]

# Each chain's Rx power is compared with the ideal the radio calculates for the link
# from the far end's Tx power and the distance, so the same levels suit any link length.
# label: (Rx power key, ideal power key)
BELOW_IDEAL = {
    "airfiber.rxpower0_below_ideal": ("airfiber.rxpower0", "airfiber.idealpwr"),
    "airfiber.rxpower1_below_ideal": ("airfiber.rxpower1", "airfiber.idealpwr"),
    "airfiber.remote_rxpower0_below_ideal": (
        "airfiber.remote_rxpower0",
        "airfiber.ridealpwr",
    ),
    "airfiber.remote_rxpower1_below_ideal": (
        "airfiber.remote_rxpower1",
        "airfiber.ridealpwr",
    ),
}

# Built-in thresholds, overridden by -w and -c. Rx power stronger than ideal gives a
# negative below_ideal value, so those ranges start at ~ rather than 0.
DEFAULT_WARNING = {
    "airfiber.rxpower0_below_ideal": "~:12",
    "airfiber.rxpower1_below_ideal": "~:12",
    "airfiber.remote_rxpower0_below_ideal": "~:12",
    "airfiber.remote_rxpower1_below_ideal": "~:12",
    "airfiber.txmodrate": "2:",
    "airfiber.remote_txmodrate": "2:",
    "airfiber.linkuptime": "3600:",
    "host.uptime": "3600:",
    "gps.sats": "5:",
    "host.mem_used": "80",
    "host.loadavg": "2",
}
DEFAULT_CRITICAL = {
    "airfiber.rxpower0_below_ideal": "~:18",
    "airfiber.rxpower1_below_ideal": "~:18",
    "airfiber.remote_rxpower0_below_ideal": "~:18",
    "airfiber.remote_rxpower1_below_ideal": "~:18",
    "host.mem_used": "90",
    "host.loadavg": "4",
}

# Pass/fail checks, overridden or added to by -o: status.cgi key: expected value
EXPECTED = {
    "airfiber.rxpower0valid": "1",
    "airfiber.rxpower1valid": "1",
    "airfiber.rxoverload0": "0",
    "airfiber.rxoverload1": "0",
    "airfiber.remote_rxpower0valid": "1",
    "airfiber.remote_rxpower1valid": "1",
    "airfiber.remote_rxoverload0": "0",
    "airfiber.remote_rxoverload1": "0",
    "gps.status": "1",
    "gps.fix": "1",
    "airfiber.data_speed": "1000Mbps-Full",
    "airfiber.linkstate": "operational",
}

# GPS dilution of precision upper bounds and the quality percentage for each.
# A DOP above the last bound is 10%.
DOP_QUALITY = [
    (1, 100),
    (1.5, 90),
    (2, 80),
    (3.5, 70),
    (5, 60),
    (7, 50),
    (10, 40),
    (15, 30),
    (20, 20),
]

# ----- Functions -----


def exit_unknown(message: str) -> None:
    """Prints an UNKNOWN result and exits"""
    inmon.handle_exit(inmon.CheckResult(3, message))


def timed_out(_signum, _frame) -> None:
    """
    Exits CRITICAL when the -t timeout is reached. Exits immediately so that
    logging out of the radio can't hold the check up any longer.
    """
    print(
        f"[CRITICAL] - No response from {BASE_URL} within {args.timeout} seconds",
        flush=True,
    )
    os._exit(2)


def parse_range(text: str) -> (bool, tuple | None | str):
    """
    Parses a threshold range in [@]start:end format.

    Returns:
        (True, (start, end, inside)), where a value alerts when it's outside start to end,
        or inside it if inside is True. (True, None) for an empty range, which turns the
        threshold off. (False, error) for an invalid range.
    """
    if text == "":
        return (True, None)
    inside = text.startswith("@")
    start_text, separator, end_text = text.lstrip("@").rpartition(":")
    try:
        start = -math.inf if start_text == "~" else float(start_text or 0)
        # With no colon the whole text is the end; "10:" has no end
        end = math.inf if separator and not end_text else float(end_text)
    except ValueError:
        return (False, f"'{text}' is not a valid range")
    if math.isnan(start) or math.isnan(end) or start > end:
        return (False, f"'{text}' is not a valid range")
    return (True, (start, end, inside))


def in_alert(value: float, alert_range: tuple | None) -> bool:
    """Returns True if a value is outside a range from parse_range, or inside an @ range"""
    if alert_range is None:
        return False
    start, end, inside = alert_range
    within = start <= value <= end
    return within if inside else not within


def perf_level(alert_range: tuple | None) -> float | None:
    """
    Returns the single level to show in performance data, which can't hold a range:
    the upper end of the range, or the start for ranges with no upper end.
    """
    if alert_range is None:
        return None
    start, end, _ = alert_range
    if math.isfinite(end):
        return end
    return start if math.isfinite(start) else None


def fetch_status(
    base_url: str, username: str, password: str, timeout: int
) -> (bool, dict | str):
    """
    Logs in to the radio, reads /status.cgi and logs out.

    Returns:
        (True, status data) or (False, error)
    """
    session = requests.Session()
    # Radios use self-signed certificates
    session.verify = False
    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
    try:
        # The login page sets the session cookie the login form needs
        logging.info("Logging in to %s as %s", base_url, username)
        session.get(f"{base_url}/login.cgi", timeout=timeout)
        response = session.post(
            f"{base_url}/login.cgi",
            # Same fields the radio's own login page sends
            data={
                "uri": "/",
                "username": username,
                "password": password,
                "login": "Login",
            },
            timeout=timeout,
        )
        if "Invalid credentials" in response.text:
            return (False, "Login failed: invalid credentials")

        # Without a valid session the radio redirects to its login page
        logging.info("Reading %s/status.cgi", base_url)
        response = session.get(
            f"{base_url}/status.cgi", timeout=timeout, allow_redirects=False
        )
        content_type = response.headers.get("Content-Type", "").split(";")[0].strip()
        if response.status_code != 200 or content_type != "application/json":
            return (
                False,
                f"Login failed: status.cgi returned HTTP {response.status_code} "
                f"({content_type or 'no content type'})",
            )
        logging.debug("status.cgi: %s", response.text)
        return (True, response.json())
    except requests.RequestException as e:
        # requests errors are long, so the full text is only logged with -vv
        logging.info("Request failed: %s", e)
        return (
            False,
            f"Unable to connect to {base_url} ({type(e).__name__}), use -vv for details",
        )
    except ValueError as e:
        return (False, f"status.cgi returned invalid JSON: {e}")
    finally:
        # Avoid leaving open sessions on the radio
        try:
            session.get(f"{base_url}/logout.cgi", timeout=timeout)
        except requests.RequestException:
            pass
        session.close()


def get_value(data: dict, key: str) -> (bool, object):
    """
    Looks up a dotted status.cgi key, e.g. airfiber.rxpower0.

    Returns:
        (True, value) or (False, error)
    """
    for part in key.split("."):
        if not isinstance(data, dict) or part not in data:
            return (False, f"{key} not found in status.cgi")
        data = data[part]
    return (True, data)


def read_metric(data: dict, label: str, key: str | None) -> (bool, int | float | str):
    """
    Returns a metric's numeric value, converting the values status.cgi reports as text.

    Returns:
        (True, value) or (False, error)
    """
    if label in BELOW_IDEAL:
        rx_key, ideal_key = BELOW_IDEAL[label]
        keys = [ideal_key, rx_key]
    elif label == "host.mem_used":
        keys = ["host.totalram", "host.freeram"]
    else:
        keys = [key]

    values = []
    for value_key in keys:
        found, value = get_value(data, value_key)
        if not found:
            return (False, value)
        values.append(value)

    try:
        if label in BELOW_IDEAL:
            ideal, rx_power = float(values[0]), float(values[1])
            return (True, ideal - rx_power)
        if label == "host.mem_used":
            total, free = float(values[0]), float(values[1])
            return (True, round((1 - free / total) * 100, 1))

        value = values[0]
        if label == "gps.dop_quality":
            dop = float(value)
            if dop <= 0:
                return (True, 0)
            return (
                True,
                next((quality for bound, quality in DOP_QUALITY if dop <= bound), 10),
            )
        if label.endswith("rxgain"):
            if value not in ("low", "high"):
                return (False, f"{key} has unexpected value {value}")
            return (True, 1 if value == "high" else 0)
        if label.endswith("txmodrate"):
            # Reported as e.g. "4x"
            return (True, int(str(value).rstrip("x")))
        if isinstance(value, bool) or not isinstance(value, (int, float)):
            return (True, float(value))
        return (True, value)
    except (TypeError, ValueError, ZeroDivisionError):
        return (
            False,
            f"{label} has unexpected value {', '.join(str(v) for v in values)}",
        )


def summarize(status: int, problems: list[tuple[int, str]]) -> (str, list[str]):
    """
    Fits as many problems as possible into the first output line and returns
    (first line text, extra lines for the rest).
    """
    # Leave room for the status prefix and a "(+N more)" note
    room = MAX_LINE_LENGTH - len(f"[{STATUS_NAMES[status]}] - ") - len(" (+99 more)")
    shown = []
    for _, text in problems:
        if shown and len(", ".join(shown + [text])) > room:
            break
        shown.append(text)
    rest = problems[len(shown) :]
    summary = ", ".join(shown) + (f" (+{len(rest)} more)" if rest else "")
    return (summary, [f"[{STATUS_NAMES[result]}] - {text}" for result, text in rest])


# ----- Main script -----

signal.signal(signal.SIGALRM, timed_out)
signal.alarm(args.timeout)

# Combine the built-in thresholds with -w and -c, then parse them all,
# so mistakes are reported before connecting to the radio
thresholds = {}
for flag, defaults, entries in (
    ("-w", DEFAULT_WARNING, args.warning),
    ("-c", DEFAULT_CRITICAL, args.critical),
):
    texts = dict(defaults)
    for entry in entries:
        if len(entry) != 2:
            exit_unknown(f"{flag} must be <label>,<range>, not {','.join(entry)}")
        label, text = entry
        if label not in METRIC_LABELS:
            exit_unknown(
                f"{flag}: unknown label {label}. Labels are listed at the top of check_airfiber.py."
            )
        texts[label] = text
    parsed = {}
    for label, text in texts.items():
        valid, result = parse_range(text)
        if not valid:
            exit_unknown(f"{flag} {label}: {result}")
        parsed[label] = result
    thresholds[flag] = parsed

expected = dict(EXPECTED)
for entry in args.override:
    if len(entry) < 2 or entry[0] == "":
        exit_unknown(f"-o must be <status.cgi key>,<value>, not {','.join(entry)}")
    if entry[0] in METRIC_LABELS:
        exit_unknown(f"-o: {entry[0]} has thresholds, so use -w or -c for it")
    # Rejoin values that contained commas
    expected[entry[0]] = ",".join(entry[1:])

ok, data = fetch_status(BASE_URL, args.username, args.password, args.timeout)
if not ok:
    exit_unknown(data)

# (status, text) for every check
results = []
perf_data = []

for label, key, uom, minimum, maximum in METRICS:
    ok, value = read_metric(data, label, key)
    if not ok:
        exit_unknown(value)
    measure = quant.Quantity(value=value, uom=uom)
    warning, critical = thresholds["-w"].get(label), thresholds["-c"].get(label)
    if in_alert(value, critical):
        result = 2
    elif in_alert(value, warning):
        result = 1
    else:
        result = 0
    results.append((result, f"{label} {measure}"))

    # PerfData shows both levels, so they're only included when both are set
    warn_level, crit_level = perf_level(warning), perf_level(critical)
    perf_threshold = None
    if warn_level is not None and crit_level is not None:
        perf_threshold = quant.Threshold(
            quant.Quantity(value=crit_level), quant.Quantity(value=warn_level)
        )
    perf_data.append(inmon.PerfData(label, measure, perf_threshold, minimum, maximum))

for key, want in expected.items():
    ok, value = get_value(data, key)
    if not ok:
        exit_unknown(value)
    passed = str(value) == want
    if passed:
        results.append((0, f"{key} is {value}"))
    else:
        results.append((2, f"{key} is {value} (expected {want})"))
    # Numeric values are graphed as they are, text values as 1 for pass and 0 for fail
    if isinstance(value, (int, float)) and not isinstance(value, bool):
        perf_value = value
    else:
        perf_value = 1 if passed else 0
    perf_data.append(inmon.PerfData(key, quant.Quantity(value=perf_value)))

# ----- Output and exit -----

status = max(result for result, _ in results)
# Critical problems first; sorted() keeps the check order within each status
problems = sorted((r for r in results if r[0] > 0), key=lambda r: -r[0])
if problems:
    summary, extra_lines = summarize(status, problems)
else:
    linkstate = data.get("airfiber", {}).get("linkstate", "state unknown")
    summary, extra_lines = f"Link {linkstate}, all checks OK", []

# -v adds a line for every passing check as well
if args.verbose >= 1:
    extra_lines += [f"[OK] - {text}" for result, text in results if result == 0]

check = inmon.CheckResult(status, summary, "\n".join(extra_lines))
for item in perf_data:
    check.add_perfdata(item)
inmon.handle_exit(check)
