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

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

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

DESCRIPTION = """Queries SMART (Self-Monitoring, Analysis, and Reporting Technology) data from hard
disks and solid-state drives using smartctl. Inspects drive health attributes,
error logs, and self-test results to detect failing or degraded drives before data
loss occurs. Supports both SCSI and ATA drives, including drives behind hardware
RAID controllers, using the device type reported by the drive scan. Devices smartctl
cannot open are reported as inaccessible instead of aborting the whole check.
Supports filtering drives by regular expression via --match and --ignore.
Alerts when any drive reports failing health attributes, SMART errors, or failed self-tests.
Requires root or sudo."""

DEFAULT_NO_MATCH_SEVERITY = 'ok'

CMD_LIST_DISKS = ['smartctl', '--scan-open']
# The device type detected by the scan is appended to this command. Without forcing the device
# type (--device option), smartctl often fails with "Unable to read device ID" (especially for
# external / USB drives and for drives behind a hardware RAID controller).
CMD_SMARTCTL = ['smartctl', '--xall']

# Drive scan used in test mode when the fixture set does not ship its own `<fixture>-scan` file,
# so the fixtures that only capture `smartctl --xall` output still run through the scan parser.
DEFAULT_TEST_SCAN = '/dev/sda -d sat # /dev/sda [SAT], ATA device'


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(
        '--full',
        help='Also warn on assumptions (stated as "notice" in '
        'GSmartControl), not just on actual SMART issues. '
        'Default: %(default)s',
        dest='FULL',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--ignore',
        help=lib.args.help('--ignore-regex'),
        dest='IGNORE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--match',
        help=lib.args.help('--match'),
        dest='MATCH',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--no-match-severity',
        help=lib.args.help('--no-match-severity') + ' Default: %(default)s',
        dest='NO_MATCH_SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_NO_MATCH_SEVERITY,
    )

    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,
    )

    args, _ = parser.parse_known_args()
    return args


def parse_scan_line(line):
    """Parse one line of `smartctl --scan-open` output.

    A line consists of the device name, the options to pass on to smartctl, and a
    comment holding the name smartctl reports the device under plus its protocol:

        /dev/sda -d sat # /dev/sda [SAT], ATA device
        /dev/bus/0 -d megaraid,0 # /dev/bus/0 [megaraid_disk_00], SCSI device

    smartctl comments out devices it was unable to open and appends the reason to the
    comment, which is what every device looks like when the scan runs unprivileged:

        # /dev/nvme0 -d nvme # /dev/nvme0, NVMe device open failed: Permission denied

    Returns a dict describing the device, or None for a line that describes none: a
    blank line, or a scan diagnostic such as `# scan_smart_devices: <error>`, which
    smartctl writes as a comment without the ` # ` separator a device line carries.
    """
    line = line.strip()
    accessible = True
    if line.startswith('#'):
        accessible = False
        line = line[1:].strip()

    arguments, separator, comment = line.partition(' # ')
    argv = arguments.split()
    if not argv or (not accessible and not separator):
        return None

    comment = comment.strip()
    return {
        'accessible': accessible,
        'comment': comment,
        # the name smartctl reports the device under, for example `/dev/sda [SAT]` or
        # `/dev/bus/0 [megaraid_disk_00]`, which is the only way to tell the drives
        # behind one RAID controller apart, as they all share a single device name.
        'info_name': comment.split(', ')[0],
        'name': argv[0],
        'options': argv[1:],
    }


def get_scan_open_error(comment):
    """Return the reason a commented-out drive scan line gives for not opening the device.

    >>> get_scan_open_error('/dev/nvme0, NVMe device open failed: Permission denied')
    Permission denied
    """
    marker = 'open failed:'
    if marker in comment:
        return comment.split(marker, 1)[1].strip()
    return comment or 'Unable to open the device.'


def get_smartctl_error(stdout, stderr):
    """Return smartctl's own error message, skipping its version banner and blank lines.

    smartctl reports why it gave up on a device in plain text, for example
    "Smartctl open device: /dev/nvme0 failed: Permission denied". That is a lot more
    useful to an admin than the generic meaning of the exit status bit.
    """
    for line in f'{stderr}\n{stdout}'.splitlines():
        line = line.strip()
        if not line or line.startswith(('smartctl ', 'Copyright ', '===')):
            continue
        return line
    return 'smartctl did not report a reason.'


def get_test_scan(test_args):
    """Return the drive scan to use in test mode.

    Fixture sets that need a specific scan (inaccessible devices, a drive behind a RAID
    controller) ship it as `<fixture>-scan` next to the `smartctl --xall` fixture. All
    others fall back to a single ATA device. `lib.lftest.test()` returns a value that
    does not resolve to a fixture unchanged, which is how a missing scan is detected.
    """
    scan_args = list(test_args)
    scan_args[0] = f'{test_args[0]}-scan'
    stdout, _, _ = lib.lftest.test(scan_args)
    if stdout == scan_args[0]:
        return DEFAULT_TEST_SCAN
    return stdout


def get_value(line):
    """>>> get_value('Model Family:     Western Digital RE4')
    Western Digital RE4
    """

    return line.split(':')[1].strip()


def get_worst(w1, w2):
    """Return the highest severity from two warning levels."""
    if 'alert' in [w1, w2]:
        return 'alert'
    if 'warn' in [w1, w2]:
        return 'warn'
    if 'notice' in [w1, w2]:
        return 'notice'
    return 'none'


def translate(smartstate, full=False):
    """Warning types in GSmartControl: none  notice   warn  alert
       Mapping in Nagios:              OK    OK/WARN  WARN  CRIT

    If full=True, gsmartcontrol.notice is a nagios.WARN, otherwise nagios.OK
    """
    if smartstate == 'alert':
        return STATE_CRIT
    if smartstate == 'warn':
        return STATE_WARN
    if smartstate == 'notice' and full:
        return STATE_WARN
    return STATE_OK


def get_sections(smartctl):
    """Split smartctl output into named sections."""
    section, subsection = 'none', 'none'
    sections = {
        'data_attributes': [],
        'data_capabilities': [],
        'data_data': [],
        'data_devstat': [],
        'data_directory_log': [],
        'data_error_log': [],
        'data_health': [],
        'data_sataphy': [],
        'data_scterc_log': [],
        'data_scttemp_log': [],
        'data_selective_selftest_log': [],
        'data_selftest_log': [],
        'info_info': [],
        'none_none': [],
    }

    for line in smartctl.splitlines():
        if 'START OF INFORMATION SECTION' in line:
            section = 'info'
            subsection = 'info'
            continue
        if 'START OF READ SMART DATA SECTION' in line:
            section = 'data'
            subsection = 'data'
            continue
        if (
            'START OF READ SMART DATA SECTION' in line
            or 'START OF ENABLE/DISABLE COMMANDS SECTION' in line
            or 'START OF OFFLINE IMMEDIATE AND SELF-TEST SECTION' in line
        ):
            section = 'none'
            subsection = 'none'
            continue
        if line.startswith('SMART overall-health self-assessment'):
            section = 'data'
            subsection = 'health'
            continue
        if line.startswith('SMART/Health Information'):
            section = 'none'
            subsection = 'none'
            continue
        if line.startswith('General SMART Values'):
            section = 'data'
            subsection = 'capabilities'
            continue
        if line.startswith('SMART Attributes Data Structure'):
            section = 'data'
            subsection = 'attributes'
            continue
        if (
            line.startswith('General Purpose Log Directory Version')
            or line.startswith('General Purpose Log Directory not supported')
            or line.startswith('General Purpose Logging (GPL) feature set supported')
            or line.startswith('Read GP Log Directory failed')
            or line.startswith('Log Directories not read due to ')
            or line.startswith('Read SMART Log Directory failed')
            or line.startswith('SMART Log Directory Version')
        ):
            section = 'data'
            subsection = 'directory_log'
            continue
        if (
            line.startswith('SMART Error Log Version')
            or line.startswith('SMART Extended Comprehensive Error Log Version')
            or line.startswith('Warning: device does not support Error Logging')
            or line.startswith('SMART Error Log not supported')
            or line.startswith('Read SMART Error Log failed')
        ):
            section = 'data'
            subsection = 'error_log'
            continue
        if (
            line.startswith(
                'SMART Extended Comprehensive Error Log (GP Log 0x03) not supported'
            )
            or line.startswith('SMART Extended Comprehensive Error Log size ')
            or line.startswith('Read SMART Extended Comprehensive Error Log failed')
        ):
            section = 'none'
            subsection = 'none'
            continue
        if (
            line.startswith('SMART Self-test log')
            or line.startswith('SMART Extended Self-test Log Version')
            or line.startswith('Warning: device does not support Self Test Logging')
            or line.startswith('Read SMART Self-test Log failed')
            or line.startswith('SMART Self-test Log not supported')
        ):
            section = 'data'
            subsection = 'selftest_log'
            continue
        if (
            line.startswith('SMART Extended Self-test Log (GP Log 0x07) not supported')
            or line.startswith('SMART Extended Self-test Log size ')
            or line.startswith('Read SMART Extended Self-test Log failed')
        ):
            section = 'none'
            subsection = 'none'
            continue
        if (
            line.startswith('SMART Selective self-test log data structure')
            or line.startswith('Device does not support Selective Self Tests/Logging')
            or line.startswith('Selective Self-tests/Logging not supported')
            or line.startswith('Read SMART Selective Self-test Log failed')
        ):
            section = 'data'
            subsection = 'selective_selftest_log'
            continue
        if (
            line.startswith('SCT Status Version')
            or line.startswith('SCT Commands not supported')
            or line.startswith('SCT Data Table command not supported')
            or line.startswith('Error unknown SCT Temperature History Format Version')
            or line.startswith(
                'Another SCT command is executing, abort Read Data Table'
            )
            or line.startswith('Warning: device does not support SCT Commands')
        ):
            section = 'data'
            subsection = 'scttemp_log'
            continue
        if (
            line.startswith('SCT Error Recovery Control')
            or line.startswith('SCT Error Recovery Control command not supported')
            or line.startswith('SCT (Get) Error Recovery Control command failed')
            or line.startswith(
                'Another SCT command is executing, abort Error Recovery Control'
            )
            or line.startswith(
                'Warning: device does not support SCT (Get) Error Recovery Control'
            )
        ):
            section = 'data'
            subsection = 'scterc_log'
            continue
        if line.startswith('Device Statistics ') or line.startswith(
            'Read Device Statistics page '
        ):
            section = 'data'
            subsection = 'devstat'
            continue
        if (
            line.startswith('SATA Phy Event Counters')
            or line.startswith('SATA Phy Event Counters (GP Log 0x11) not supported')
            or line.startswith('SATA Phy Event Counters with ')
            or line.startswith('Read SATA Phy Event Counters failed')
        ):
            section = 'data'
            subsection = 'sataphy'

        sections[f'{section}_{subsection}'].append(line.strip())

    return sections


def parse_section_info(section):
    """Parse the INFORMATION SECTION for model, serial, and warnings."""
    # Model Family:     Seagate Barracuda LP
    # Device Model:     ST32000542AS
    # Serial Number:    LINUXFABRIK
    # LU WWN Device Id: 5 000c50 02ab8edf5
    # Firmware Version: CC34
    # User Capacity:    2'000'398'934'016 bytes [2.00 TB]
    # Sector Size:      512 bytes logical/physical
    # Rotation Rate:    5900 rpm
    # Device is:        In smartctl database [for details use: -P show]
    # ATA Version is:   ATA8-ACS T13/1699-D revision 4
    # SATA Version is:  SATA 2.6, 3.0 Gb/s
    # Local Time is:    Tue Dec  3 13:44:25 2019 CET
    #
    # ==> WARNING: A firmware update for this drive may be available,
    # see the following Seagate web pages:
    # http://knowledge.seagate.com/articles/en_US/FAQ/207931en
    # http://knowledge.seagate.com/articles/en_US/FAQ/213915en
    #
    # ==> WARNING: I am another possible warning
    # see the following Seagate web pages:
    # http://knowledge.seagate.com/articles/en_US/FAQ/207931en
    # http://knowledge.seagate.com/articles/en_US/FAQ/213915en
    #
    # ==> WARNING: >> Terminate command early due to bad response to IEC mode page
    # see the following Seagate web pages: I will not be outputted as a warning
    # http://knowledge.seagate.com/articles/en_US/FAQ/207931en
    # http://knowledge.seagate.com/articles/en_US/FAQ/213915en
    #
    # SMART support is: Available - device has SMART capability.
    # SMART support is: Enabled
    # AAM level is:     0 (vendor specific), recommended: 254
    # APM level is:     192 (intermediate level without standby)
    # Rd look-ahead is: Enabled
    # Write cache is:   Enabled
    # DSN feature is:   Unavailable
    # ATA Security is:  Disabled, NOT FROZEN [SEC1]
    # Wt Cache Reorder: Unknown
    #
    p = {}  # property
    p['model'] = ''
    p['device_model'] = 'Unknown model'
    p['serial_number'] = None
    p['w'] = 'none'
    p['msg'] = ''

    expecting_warning_lines = False
    warning_line = ''
    warning = ''

    for line in section:
        if expecting_warning_lines:
            if line == '':
                expecting_warning_lines = False
                #  Some errors get in the way of subsection detection and have little value,
                # ignore them.
                if (
                    'mandatory SMART command failed' not in warning_line
                    and 'Enabled status cached by OS, trying SMART RETURN STATUS cmd'
                    not in warning_line
                    and not warning_line.startswith('Unexpected SCT status')
                    and not warning_line.startswith(
                        'Write SCT (Get) XXX Error Recovery Control Command failed'
                    )
                    and not warning_line.startswith(
                        'Write SCT (Get) Feature Control Command failed'
                    )
                    and not warning_line.startswith('Read SCT Status failed')
                    and not warning_line.startswith('Read SMART Data failed')
                    and not warning_line.startswith('Unknown SCT Status format version')
                    and not warning_line.startswith('Read SMART Thresholds failed')
                    and not warning_line.startswith(
                        '>> Terminate command early due to bad response to IEC mode page'
                    )
                    and not warning_line.startswith('scsiModePageOffset:')
                    and not warning_line.startswith(
                        'This firmware returns bogus raw values'
                    )
                ):
                    p['w'] = get_worst(
                        'notice', p['w']
                    )  # storage_property_autoset_warning
                    warning += '    ' + warning_line
                warning_line = ''
            else:
                warning_line += '    ' + line + '\n'

        # Sometimes, we get this in the middle of Info section (separated by double newlines):
        if line.startswith('==> WARNING: '):
            expecting_warning_lines = True
            warning_line += line.replace('==> WARNING: ', '') + '\n'

        if line.startswith('Model Family:'):
            p['model'] += get_value(line) + ' '
            continue
        if line.startswith('Model Number:'):
            p['model'] += get_value(line) + ' '
            continue
        if line.startswith('Product:'):
            p['model'] += get_value(line) + ' '
            continue
        if line.startswith('Device Model:'):
            p['device_model'] = get_value(line)
            continue
        if line.lower().startswith('serial number:'):
            p['serial_number'] = get_value(line)
            continue
        if line.startswith('SMART support is'):
            if 'Disabled' in line:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + '  - Info: SMART is disabled. You should enable '
                    'it to read any SMART information from this '
                    'drive. Additionally, some drives do not log '
                    "useful data with SMART disabled, so it's "
                    'advisable to keep it always enabled.\n'
                )
                continue
            if 'Unavailable' in line:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + "  - Info: SMART is not supported. You won't be "
                    'able to read any SMART information from this '
                    'drive. Consider adding it to the ignore list.\n'
                )

    p['model'] = lib.txt.uniq(p['model'].strip())

    if warning:
        p['msg'] += f'  - There are warnings, your drive may be affected:\n{warning}'
    return p


def parse_section_data_subsection_health(section):
    """Parse the health self-assessment section."""
    # Drive failure expected in less than 24 hours. SAVE ALL DATA.
    # See vendor-specific Attribute list for failed Attributes.
    #
    p = {}  # property
    p['w'] = 'none'
    p['msg'] = ''
    for line in section:
        if line:
            p['w'] = get_worst('alert', p['w'])
            p['msg'] = p.get('msg', '') + line + ' '

    if p['msg']:
        p['msg'] = '  - Health: ' + p['msg'][:-1]
    return p


def parse_section_data_subsection_attributes(section):
    """Parse SMART attributes for failing or degraded values."""
    # Vendor Specific SMART Attributes with Thresholds:
    # ID# ATTRIBUTE_NAME          FLAGS    VALUE WORST THRESH FAIL RAW_VALUE
    # 1 Raw_Read_Error_Rate     POSR--   095   093   006    -    206969285
    # 3 Spin_Up_Time            PO----   100   100   000    -    0
    # 4 Start_Stop_Count        -O--CK   100   100   020    -    28
    # 5 Reallocated_Sector_Ct   PO--CK   004   004   036    NOW  3956
    # 7 Seek_Error_Rate         POSR--   074   060   030    -    27571332
    # 9 Power_On_Hours          -O--CK   080   080   000    -    18319
    # 10 Spin_Retry_Count        PO--C-   100   100   097    -    0
    # 12 Power_Cycle_Count       -O--CK   100   100   020    -    27
    # 183 Runtime_Bad_Block       -O--CK   096   096   000    -    4
    # 184 End-to-End_Error        -O--CK   100   100   099    -    0
    # 187 Reported_Uncorrect      -O--CK   100   100   000    -    0
    # 188 Command_Timeout         -O--CK   100   099   000    -    8590065667
    # 189 High_Fly_Writes         -O-RCK   100   100   000    -    0
    # 190 Airflow_Temperature_Cel -O---K   078   058   045    -    22 (Min/Max 22/22)
    # 194 Temperature_Celsius     -O---K   022   042   000    -    22 (0 20 0 0 0)
    # 195 Hardware_ECC_Recovered  -O-RC-   023   017   000    -    206969285
    # 197 Current_Pending_Sector  -O--C-   100   100   000    -    0
    # 198 Offline_Uncorrectable   ----C-   100   100   000    -    0
    # 199 UDMA_CRC_Error_Count    -OSRCK   200   200   000    -    4
    # 240 Head_Flying_Hours       ------   100   253   000    -    18386 (132 95 0)
    # 241 Total_LBAs_Written      ------   100   253   000    -    3973655019
    # 242 Total_LBAs_Read         ------   100   253   000    -    3238867291
    # ||||||_ K auto-keep
    # |||||__ C event count
    # ||||___ R error rate
    # |||____ S speed/performance
    # ||_____ O updated online
    # |______ P prefailure warning
    #
    p = {}  # property
    p['w'] = 'none'
    p['msg'] = ''
    p['perfdata'] = {}
    for line in section:
        splitted_line = line.split()
        if len(splitted_line) < 7:
            continue

        smartid = splitted_line[0]
        attribute = splitted_line[1]
        flags = splitted_line[2]
        value = splitted_line[3]
        fail = splitted_line[6]
        raw_value = splitted_line[7]

        sensor_name = f'{smartid}_{attribute}'

        # First go through all attributes and check the FAIL column.
        # If it's 'NOW' set the state and go to the next line, else we look at the values
        if 'NOW' in fail:
            # check if pre-fail or old_age
            if 'P' in flags:
                # pre-fail
                p['w'] = get_worst('alert', p['w'])
                p['msg'] = (
                    p.get('msg', '') + f'  - Attributes: Drive has a failing pre-fail '
                    f'attribute ("{smartid} {attribute}"). Usually '
                    f'this indicates that the drive will FAIL soon. '
                    f'Please back up immediately!\n'
                )
            else:
                # old_age
                p['w'] = get_worst('warn', p['w'])
                p['msg'] = (
                    p.get('msg', '') + f'  - Attributes: Drive has a failing old-age '
                    f'attribute ("{smartid} {attribute}"). Usually '
                    f'this indicates a wear-out. You should '
                    f'consider replacing the drive.\n'
                )
            continue
        if 'Past' in fail:
            # check if pre-fail or old_age
            flags = splitted_line[2]
            if 'P' in flags:
                # pre-fail
                p['w'] = get_worst('warn', p['w'])
                p['msg'] = (
                    p.get('msg', '') + f'  - Attributes: Drive had a failing pre-fail '
                    f'attribute ("{smartid} {attribute}"), but it '
                    f'has been restored to a normal value. This may '
                    f'be a serious problem, you should consider '
                    f'replacing the drive.\n'
                )
            # else old_age - do nothing. We don't warn about e.g. temperature increase in the past
            continue

        if line.startswith('5 Reallocated_Sector_Ct'):
            raw_value = int(raw_value)
            if raw_value > 0:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + f'  - Attributes: Drive has a non-zero Raw '
                    f'value ("{smartid} {attribute}"), but there '
                    f'is no SMART warning yet. This could be an '
                    f'indication of future failures and/or '
                    f'potential data loss in bad sectors.\n'
                )

        if line.startswith('10 Spin_Retry_Count'):
            raw_value = int(raw_value)
            if raw_value > 0:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + f'  - Attributes: Drive has a non-zero Raw '
                    f'value ("{smartid} {attribute}"), but there '
                    f'is no SMART warning yet. Your drive may have '
                    f'problems spinning up, which could lead to a '
                    f'complete mechanical failure. Please back up.\n'
                )

        if line.startswith('13 Read_Soft_Error_Rate'):
            raw_value = int(raw_value)
            if raw_value > 0:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + f'  - Attributes: Drive has a non-zero Raw '
                    f'value ("{smartid} {attribute}"), but there '
                    f'is no SMART warning yet. This could be an '
                    f'indication of future failures and/or '
                    f'potential data loss in bad sectors.\n'
                )

        if (
            line.startswith('190 Temperature_Celsius')
            or line.startswith('194 Temperature_Celsius')
            or line.startswith('194 Airflow_Temperature')
            or line.startswith('194 Temperature_Celsius')
            or line.startswith('194 Temperature_Internal')
            or line.startswith('231 Temperature_Celsius')
        ):
            raw_value = int(raw_value)
            p['perfdata'][sensor_name] = raw_value
            if raw_value > 50:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + '  - Attributes: The temperature of drive is '
                    'higher than 50 degrees Celsius. This may '
                    'shorten its lifespan and cause damage under '
                    'severe load. Please install a cooling '
                    'solution.\n'
                )

        if line.startswith('194 Temperature_Celsius_x10'):
            raw_value = int(raw_value)
            p['perfdata'][sensor_name] = raw_value
            if raw_value > 500:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + '  - Attributes: The temperature of drive is '
                    'higher than 50 degrees Celsius. This may '
                    'shorten its lifespan and cause damage under '
                    'severe load. Please install a cooling '
                    'solution.\n'
                )

        if line.startswith('196 Reallocated_Event_Count'):
            raw_value = int(raw_value)
            if raw_value > 0:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + f'  - Attributes: Drive has a non-zero Raw '
                    f'value ("{smartid} {attribute}"), but there '
                    f'is no SMART warning yet. This could be an '
                    f'indication of future failures and/or '
                    f'potential data loss in bad sectors.\n'
                )

        if line.startswith('197 Current_Pending_Sector') or line.startswith(
            '197 Total_Pending_Sectors'
        ):
            raw_value = int(raw_value)
            if raw_value > 0:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + f'  - Attributes: Drive has a non-zero Raw '
                    f'value ("{smartid} {attribute}"), but there '
                    f'is no SMART warning yet. This could be an '
                    f'indication of future failures and/or '
                    f'potential data loss in bad sectors.\n'
                )

        if line.startswith('198 Offline_Uncorrectable') or line.startswith(
            '198 Total_Offl_Uncorrectabl'
        ):
            raw_value = int(raw_value)
            if raw_value > 0:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + f'  - Attributes: Drive has a non-zero Raw '
                    f'value ("{smartid} {attribute}"), but there '
                    f'is no SMART warning yet. This could be an '
                    f'indication of future failures and/or '
                    f'potential data loss in bad sectors.\n'
                )

        if (
            line.startswith('5 Retired_Block_Count')
            or line.startswith('169 Remaining_Lifetime_Perc')
            or line.startswith('202 Percent_Lifetime_Remain')
            or line.startswith('209 Remaining_Lifetime_Perc')
            or line.startswith('231 SSD_Life_Left')
            or line.startswith('232 Perc_Avail_Resrvd_Space')
            or line.startswith('232 Spares_Remaining_Perc')
            or line.startswith('233 Media_Wearout_Indicator')
            or line.startswith('233 Remaining_Lifetime_Perc')
        ):
            value = int(value)
            p['perfdata'][sensor_name] = value
            if value < 50:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + f'  - Attributes: Drive has less than half of '
                    f'its estimated life left '
                    f'("{smartid} {attribute}").\n'
                )

        if line.startswith('202 Percent_Lifetime_Used'):
            raw_value = int(raw_value)
            p['perfdata'][sensor_name] = raw_value
            if raw_value >= 50:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + f'  - Attributes: Drive has less than half of '
                    f'its estimated life left '
                    f'("{smartid} {attribute}").\n'
                )

        # collect perfdata for some more attributes
        if (
            line.startswith('4 Start')
            or line.startswith('9 Power')
            or line.startswith('12 Power')
        ):
            if raw_value.isnumeric():
                raw_value = int(raw_value)
            else:
                # some drives report something like
                # "45449h+00m+00.000s" (e.g. SanDisk SSDs)
                raw_value = int(raw_value.split('h', 1)[0])

            p['perfdata'][sensor_name] = raw_value

    if p['msg']:
        p['msg'] = p['msg'][:-1]
    return p


def parse_section_data_subsection_error_log(section):
    """Parse the error log for device error types."""
    # Device Error Count: 1
    # CR     = Command Register
    # FEATR  = Features Register
    # COUNT  = Count (was: Sector Count) Register
    # LBA_48 = Upper bytes of LBA High/Mid/Low Registers ]  ATA-8
    # LH     = LBA High (was: Cylinder High) Register    ]   LBA
    # LM     = LBA Mid (was: Cylinder Low) Register      ] Register
    # LL     = LBA Low (was: Sector Number) Register     ]
    # DV     = Device (was: Device/Head) Register
    # DC     = Device Control Register
    # ER     = Error register
    # ST     = Status register
    # Powered_Up_Time is measured from power on, and printed as
    # DDd+hh:mm:SS.sss where DD=days, hh=hours, mm=minutes,
    # SS=sec, and sss=millisec. It "wraps" after 49.710 days.
    #
    # Error 1 [0] occurred at disk power-on lifetime: 7922 hours (330 days + 2 hours)
    # When the command that caused the error occurred, the device was active or idle.
    #
    # After command completion occurred, registers were:
    # ER -- ST COUNT  LBA_48  LH LM LL DV DC
    # -- -- -- == -- == == == -- -- -- -- --
    # 84 -- 51 00 57 00 00 01 14 50 4b 01 00  Error: ICRC, ABRT at LBA = 0x0114504b = 18108491
    #
    # Commands leading to the command that caused the error were:
    # CR FEATR COUNT  LBA_48  LH LM LL DV DC  Powered_Up_Time  Command/Feature_Name
    # -- == -- == -- == == == -- -- -- -- --  ---------------  --------------------
    # 61 00 80 00 80 00 00 01 14 50 a2 40 00 26d+11:39:40.376  WRITE FPDMA QUEUED
    # 61 00 80 00 d0 00 00 01 14 50 22 40 00 26d+11:39:40.372  WRITE FPDMA QUEUED
    # 61 00 80 00 38 00 00 01 14 4f a2 40 00 26d+11:39:40.369  WRITE FPDMA QUEUED
    # 61 00 80 00 20 00 00 01 14 4f 22 40 00 26d+11:39:40.366  WRITE FPDMA QUEUED
    # 61 00 80 00 10 00 00 01 14 4e a2 40 00 26d+11:39:40.363  WRITE FPDMA QUEUED
    #
    p = {}  # property
    p['w'] = 'none'
    p['msg'] = ''
    regex_error_count = re.compile(
        r'^(?:ATA|Device) Error Count:[ \t]*([0-9]+)', re.M | re.I
    )
    for line in section:
        matches = re.search(regex_error_count, line)
        if matches:
            p['w'] = get_worst('notice', p['w'])
            p['msg'] = (
                p.get('msg', '') + f'  - Error Log: Drive is reporting '
                f'{matches.group(1)} internal errors. Usually '
                f'this means uncorrectable data loss and similar '
                f'severe errors. Check the actual errors for '
                f'details.\n'
            )

        # GSmartControl:src/applib/storage_property.cpp:get_warning_level_for_error_type
        if 'AMNF' in line:
            p['w'] = get_worst('alert', p['w'])
            p['msg'] = (
                p.get('msg', '') + '  - Error Log: Error "Address mark not found".\n'
            )

        if 'CCTO' in line:
            p['w'] = get_worst('warn', p['w'])
            p['msg'] = (
                p.get('msg', '')
                + '  - Error Log: Error "Command completion timed out".\n'
            )

        if 'EOM' in line:
            p['w'] = get_worst('warn', p['w'])
            p['msg'] = p.get('msg', '') + '  - Error Log: Error "End of media".\n'

        if 'ICRC' in line:
            p['w'] = get_worst('warn', p['w'])
            p['msg'] = (
                p.get('msg', '') + '  - Error Log: Error "Interface CRC error".\n'
            )

        if 'IDNF' in line:
            p['w'] = get_worst('alert', p['w'])
            p['msg'] = p.get('msg', '') + '  - Error Log: Error "Identity not found".\n'

        if 'ILI' in line:
            p['w'] = get_worst('notice', p['w'])
            p['msg'] = (
                p.get('msg', '')
                + '  - Error Log: Error "Packet command-set specific".\n'
            )

        if 'TK0NF' in line:
            p['w'] = get_worst('alert', p['w'])
            p['msg'] = p.get('msg', '') + '  - Error Log: Error "Track 0 not found".\n'

        if 'UNC' in line:
            p['w'] = get_worst('alert', p['w'])
            p['msg'] = (
                p.get('msg', '')
                + '  - Error Log: Error "Uncorrectable error in data".\n'
            )

    if p['msg']:
        p['msg'] = p['msg'][:-1]
    return p


def parse_section_data_subsection_devstat(section):
    """Parse device statistics for surface errors and temperature."""
    # Page  Offset Size        Value Flags Description
    # 0x01  =====  =               =  ===  == General Statistics (rev 1) ==
    # 0x01  0x008  4              41  ---  Lifetime Power-On Resets
    # 0x01  0x010  4           33577  ---  Power-on Hours
    # 0x01  0x018  6    246546887344  ---  Logical Sectors Written
    # 0x01  0x020  6      4648558254  ---  Number of Write Commands
    # 0x01  0x028  6    462376778658  ---  Logical Sectors Read
    # 0x01  0x030  6      2947763659  ---  Number of Read Commands
    # 0x03  =====  =               =  ===  == Rotating Media Statistics (rev 1) ==
    # 0x03  0x008  4           33570  ---  Spindle Motor Power-on Hours
    # 0x03  0x010  4           33570  ---  Head Flying Hours
    # 0x03  0x018  4             196  ---  Head Load Events
    # 0x03  0x020  4               1  ---  Number of Reallocated Logical Sectors
    # 0x03  0x028  4          132809  ---  Read Recovery Attempts
    # 0x03  0x030  4               2  ---  Number of Mechanical Start Failures
    # 0x04  =====  =               =  ===  == General Errors Statistics (rev 1) ==
    # 0x04  0x008  4               0  ---  Number of Reported Uncorrectable Errors
    # 0x04  0x010  4             150  ---  Resets Between Cmd Acceptance and Completion
    # 0x05  =====  =               =  ===  == Temperature Statistics (rev 1) ==
    # 0x05  0x008  1              23  ---  Current Temperature
    # 0x05  0x010  1              34  N--  Average Short Term Temperature
    # 0x05  0x018  1              32  N--  Average Long Term Temperature
    # 0x05  0x020  1              53  ---  Highest Temperature
    # 0x05  0x028  1              20  ---  Lowest Temperature
    # 0x05  0x030  1              47  N--  Highest Average Short Term Temperature
    # 0x05  0x038  1               0  N--  Lowest Average Short Term Temperature
    # 0x05  0x040  1              32  N--  Highest Average Long Term Temperature
    # 0x05  0x048  1               0  N--  Lowest Average Long Term Temperature
    # 0x05  0x050  4               0  ---  Time in Over-Temperature
    # 0x05  0x058  1              60  ---  Specified Maximum Operating Temperature
    # 0x05  0x060  4               0  ---  Time in Under-Temperature
    # 0x05  0x068  1               0  ---  Specified Minimum Operating Temperature
    # 0x06  =====  =               =  ===  == Transport Statistics (rev 1) ==
    # 0x06  0x008  4           14029  ---  Number of Hardware Resets
    # 0x06  0x010  4             360  ---  Number of ASR Events
    # 0x06  0x018  4               2  ---  Number of Interface CRC Errors
    # |||_ C monitored condition met
    # ||__ D supports DSN
    # |___ N normalized value
    #
    # Pending Defects log (GP Log 0x0c) not supported
    #
    p = {}  # property
    p['w'] = 'none'
    p['msg'] = ''
    for line in section:
        splitted_line = line.split()
        if len(splitted_line) < 5:
            continue
        value = splitted_line[3]
        flags = splitted_line[4]
        description = ' '.join(splitted_line[5:])

        # GSmartControl: storage_property_descr.cpp:
        # case StorageProperty::subsection_devstat
        if 'Pending Error Count' in description:
            value = int(value)
            if value > 0:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + f'  - Statistics: Drive is reporting surface '
                    f'errors ("{description}"). This could be an '
                    f'indication of future failures and/or '
                    f'potential data loss in bad sectors.\n'
                )

        if (
            'Utilization Usage Rate' in description
            or 'Percentage Used Endurance Indicator' in description
        ):
            value = int(value)
            if value >= 50:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + f'  - Statistics: Drive is reporting surface '
                    f'errors ("{description}"). This could be an '
                    f'indication of future failures and/or '
                    f'potential data loss in bad sectors.\n'
                )
            if value >= 100:
                p['w'] = get_worst('warn', p['w'])
                p['msg'] = (
                    p.get('msg', '')
                    + '  - Statistics: Drive is past its estimated lifespan.\n'
                )

        if 'Number of Reallocated Logical Sectors' in description:
            # check if value is normalized or not
            value = int(value)
            if 'N' in flags:
                # normalized
                if value <= 0:
                    p['w'] = get_worst('warn', p['w'])
                    p['msg'] = (
                        p.get('msg', '')
                        + f'  - Statistics: Drive is reporting surface '
                        f'errors ("{description}"). This could be an '
                        f'indication of future failures and/or '
                        f'potential data loss in bad sectors.\n'
                    )
            else:
                # not normalized
                if value > 0:
                    p['w'] = get_worst('notice', p['w'])
                    p['msg'] = (
                        p.get('msg', '')
                        + f'  - Statistics: Drive is reporting surface '
                        f'errors ("{description}"). This could be an '
                        f'indication of future failures and/or '
                        f'potential data loss in bad sectors.\n'
                    )

        if 'Number of Mechanical Start Failures' in description:
            value = int(value)
            if value > 0:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + f'  - Statistics: Drive is reporting mechanical '
                    f'errors ("{description}").\n'
                )

        if (
            'Number of Realloc. Candidate Logical Sectors' in description
            or 'Number of Reported Uncorrectable Errors' in description
        ):
            value = int(value)
            if value > 0:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + f'  - Statistics: Drive is reporting surface '
                    f'errors ("{description}"). This could be an '
                    f'indication of future failures and/or '
                    f'potential data loss in bad sectors.\n'
                )

        if 'Current Temperature' in description:
            value = int(value)
            if value > 50:
                p['w'] = get_worst('notice', p['w'])
                p['msg'] = (
                    p.get('msg', '') + '  - Statistics: The temperature of the drive '
                    'is higher than 50 degrees Celsius. This may '
                    'shorten its lifespan and cause damage under '
                    'severe load. Please install a cooling '
                    'solution.\n'
                )

        if 'Time in Over-Temperature' in description:
            try:
                value = int(value)
                if value > 0:
                    p['w'] = get_worst('notice', p['w'])
                    p['msg'] = (
                        p.get('msg', '') + '  - Statistics: The temperature of the '
                        'drive is or was over the manufacturer-'
                        'specified maximum. This may have shortened '
                        'its lifespan and caused damage. Please '
                        'install a cooling solution.\n'
                    )
            except Exception:
                pass

        if 'Time in Under-Temperature' in description:
            try:
                value = int(value)
                if value > 0:
                    p['w'] = get_worst('notice', p['w'])
                    p['msg'] = (
                        p.get('msg', '') + '  - Statistics: The temperature of the '
                        'drive is or was under the manufacturer-'
                        'specified maximum. This may have shortened '
                        'its lifespan and caused damage. Please '
                        'operate the drive within manufacturer-'
                        'specified temperature range.\n'
                    )
            except Exception:
                pass

    if p['msg']:
        p['msg'] = p['msg'][:-1]
    return p


def parse_sections(smartctl):
    """Parse all smartctl sections and return a combined report."""
    report = {}
    sections = get_sections(smartctl)
    # ('none', 'none')
    # do nothing
    # ('data', 'health')
    report['health'] = parse_section_data_subsection_health(sections['data_health'])
    # ('data', 'capabilities')
    # do nothing
    # ('data', 'attributes')
    report['attributes'] = parse_section_data_subsection_attributes(
        sections['data_attributes']
    )
    # ('data', 'directory_log')
    # do nothing
    # ('data', 'error_log')
    report['error_log'] = parse_section_data_subsection_error_log(
        sections['data_error_log']
    )
    # ('data', 'selftest_log')
    # do nothing
    # ('data', 'selective_selftest_log')
    # do nothing
    # ('data', 'scttemp_log')
    # do nothing
    # ('data', 'scterc_log')
    # do nothing
    # ('data', 'devstat')
    report['statistics'] = parse_section_data_subsection_devstat(
        sections['data_devstat']
    )
    # ('data', 'sataphy')
    # do nothing
    # ('info', 'info')
    report['general'] = parse_section_info(sections['info_info'])

    return report


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

    # fetch data. `smartctl --scan-open` writes benign warnings about
    # unscannable devices to stderr while still listing the real disks on
    # stdout, so stderr is not treated as a failure here.
    if args.TEST is None:
        scan, _stderr, _retc = lib.base.coe(lib.shell.shell_exec(CMD_LIST_DISKS))
    else:
        scan = get_test_scan(args.TEST)

    # scan:
    # /dev/sda -d sat # /dev/sda [SAT], ATA device
    # /dev/sdb -d scsi # /dev/sdb, SCSI device
    # /dev/nvme0 -d nvme # /dev/nvme0, NVMe device

    # init some vars
    msg_body = ''
    state = STATE_OK
    perfdata = ''
    disk_count = 0
    scan_count = 0
    inaccessible = []
    scan_errors = []
    # `--ignore` used to take a comma-separated list of disk names, so keep splitting on
    # commas to stay compatible with existing service definitions.
    ignore = [item for entry in args.IGNORE for item in entry.split(',') if item]
    compiled_match = [
        lib.base.coe(item) for item in lib.txt.compile_regex(args.MATCH, '--match')
    ]
    compiled_ignore = [
        lib.base.coe(item) for item in lib.txt.compile_regex(ignore, '--ignore')
    ]

    # analyze data
    for line in scan.splitlines():
        device = parse_scan_line(line)
        if device is None:
            # a comment that does not describe a device is a diagnostic about the scan
            # itself, for example `# scan_smart_devices: <error>`
            if line.strip().startswith('#'):
                scan_errors.append(line.strip().lstrip('#').strip())
            continue
        scan_count += 1

        # Filter devices. --match (include) is applied first, then --ignore (exclude), so a
        # device hit by --ignore is dropped even if it also matches --match. The device path
        # (`/dev/sda`), the short device name (`sda`) and the name smartctl reports the
        # device under (`/dev/bus/0 [megaraid_disk_00]`) are all matched, so an anchored
        # pattern works against either spelling and a single drive behind a RAID controller
        # can be filtered although it shares its device name with all the others.
        names = (device['name'], os.path.basename(device['name']), device['info_name'])
        if compiled_match and not any(
            item.search(name) for item in compiled_match for name in names
        ):
            continue
        if any(item.search(name) for item in compiled_ignore for name in names):
            continue

        # smartctl comments out the devices it was unable to open. Running without root
        # privileges comments out every single one of them, so report the reason smartctl
        # gives instead of running the device name into a misleading follow-up error.
        if not device['accessible']:
            inaccessible.append(
                (device['name'], get_scan_open_error(device['comment']))
            )
            state = lib.base.get_worst(STATE_UNKNOWN, state)
            continue

        # fetch data. smartctl reads the device name off the end of its command line
        # (`argv[argc-1]`), so the options from the scan have to come first. Relying on
        # getopt to permute the arguments would break wherever it does not: with
        # POSIXLY_CORRECT set in the environment, and on a C library that does not
        # implement the GNU permutation in the first place.
        if args.TEST is None:
            cmd_smartctl = [*CMD_SMARTCTL, *device['options'], device['name']]
            smartctl, stderr, retc = lib.base.coe(lib.shell.shell_exec(cmd_smartctl))
        else:
            # do not call the command, put in test data
            smartctl, stderr, retc = lib.lftest.test(args.TEST)

        # compare the retc using bitmasks according to the man page of smartctl.
        # Bit 0 ("command line did not parse") in practice means that smartctl was unable to
        # detect the device type, bit 1 that it could not open the device at all. One device
        # we cannot talk to must not abort the whole check, so report smartctl's own message
        # and carry on with the remaining disks.
        if retc & 3:
            inaccessible.append((device['name'], get_smartctl_error(smartctl, stderr)))
            state = lib.base.get_worst(STATE_UNKNOWN, state)
            continue

        disk_msg = ''
        disk_state = STATE_OK
        smart_msg = ''

        # retc 4 is useless
        if retc & 8:
            smart_msg += '  - SMART status check returned DISK FAILING.\n'
            disk_state = lib.base.get_worst(STATE_CRIT, disk_state)
        if retc & 16:
            smart_msg += '  - We found prefail Attributes <= threshold.\n'
            disk_state = lib.base.get_worst(STATE_WARN, disk_state)
        if retc & 32:
            smart_msg += (
                '  - SMART status check returned DISK OK but we '
                'found that some (usage or prefail) Attributes have '
                'been <= threshold at some time in the past.\n'
            )
            disk_state = lib.base.get_worst(STATE_WARN, disk_state)
        if retc & 64:
            smart_msg += '  - The device error log contains records of errors.\n'
            disk_state = lib.base.get_worst(STATE_WARN, disk_state)
        if retc & 128:
            smart_msg += (
                '  - The device self-test log contains records of '
                'errors. Failed self-tests outdated by a newer '
                'successful extended self-test are ignored.\n'
            )
            disk_state = lib.base.get_worst(STATE_WARN, disk_state)

        disk_report = parse_sections(smartctl)
        disk_count += 1
        serial_number = disk_report['general']['serial_number']

        for item in disk_report.values():
            if item['w'] != 'none':
                disk_msg += item['msg'] + '\n'
                disk_state = lib.base.get_worst(
                    translate(item['w'], args.FULL), disk_state
                )
            if item.get('perfdata'):
                for sensor_name, sensor_value in item['perfdata'].items():
                    device_model = disk_report['general']['device_model'].replace(
                        ' ', '_'
                    )
                    perfdata += lib.base.get_perfdata(
                        f'{device["name"]}--{device_model}--{serial_number}'
                        f'--{sensor_name}',
                        sensor_value,
                        _min=0,
                    )

        msg_body += (
            f'* {device["name"]}'
            f' ({disk_report["general"]["model"]}'
            f', {disk_report["general"]["device_model"]}'
            f', SerNo {disk_report["general"]["serial_number"]})'
            f'{lib.base.state2str(disk_state, prefix=" ")}'
            f'\n'
        )
        msg_body += smart_msg
        msg_body += disk_msg
        state = lib.base.get_worst(disk_state, state)

    # build the message
    if not scan_count:
        msg = 'Did not find any disk.'
        if scan_errors:
            msg += ' ' + ' '.join(scan_errors)
        lib.base.oao(msg, STATE_UNKNOWN, always_ok=args.ALWAYS_OK)
    if not disk_count and not inaccessible:
        lib.base.oao(
            'Nothing checked.',
            lib.base.str2state(args.NO_MATCH_SEVERITY),
            always_ok=args.ALWAYS_OK,
        )

    for name, reason in inaccessible:
        msg_body += (
            f'* {name}: {reason}{lib.base.state2str(STATE_UNKNOWN, prefix=" ")}\n'
        )

    if disk_count:
        msg_header = f'Checked {disk_count} {lib.txt.pluralize("disk", disk_count)}.'
        if state == STATE_CRIT:
            msg_header += ' There are critical errors.'
        elif state == STATE_WARN:
            msg_header += ' There are warnings.'
        elif not inaccessible:
            msg_header += ' All are healthy.'
    else:
        msg_header = 'Did not check any disk.'
    if inaccessible:
        count = len(inaccessible)
        msg_header += (
            f' smartctl cannot access {count} {lib.txt.pluralize("device", count)}. '
            f'Run this plugin as root or via sudo.'
        )

    # over and out
    lib.base.oao(
        f'{msg_header}\n{msg_body}',
        state,
        perfdata,
        always_ok=args.ALWAYS_OK,
        no_perfdata=args.NO_PERFDATA,
    )


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