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

import lib.args
import lib.base
import lib.db_sqlite
import lib.disk
import lib.txt
import lib.url
from lib.globals import STATE_OK, STATE_UNKNOWN

missing_lib = None
try:
    import lib.smb

    HAVE_SMB = True
except ModuleNotFoundError as e:
    HAVE_SMB = False
    missing_lib = e.name

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

DESCRIPTION = """Imports a CSV file (local, remote via URL, or from an SMB share) into a temporary
SQLite database and runs configurable SQL queries against it. Separate queries can be
defined for warning and critical conditions. The query result - either a row count or a
specific value - is checked against Nagios range expressions. This makes it possible to
monitor any data source that can export CSV."""

DEFAULT_CHUNKSIZE = 1000
DEFAULT_DELIMITER = ','
DEFAULT_INSECURE = False
DEFAULT_NEWLINE = None
DEFAULT_NO_PROXY = False
DEFAULT_QUOTECHAR = '"'
DEFAULT_SKIP_HEADER = False
DEFAULT_TIMEOUT = 3


def parse_args():
    """Parse command line arguments using argparse."""
    parser = argparse.ArgumentParser(description=DESCRIPTION)

    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(
        '--chunksize',
        help='Breaks up the transfer of data from the csv to the SQLite database in chunks as '
        'to not run out of memory. '
        'Default: %(default)s',
        dest='CHUNKSIZE',
        type=int,
        default=DEFAULT_CHUNKSIZE,
    )

    parser.add_argument(
        '--columns-query',
        help='Describe the columns and their datatypes using an sql statement. '
        'Example: `"col1 INTEGER PRIMARY KEY, col2 TEXT NOT NULL, col3 TEXT NOT NULL UNIQUE"`',
        dest='COLUMNS_QUERY',
        required=True,
    )

    parser.add_argument(
        '-c',
        '--critical',
        help='CRIT threshold. Supports ranges.',
        dest='CRIT',
    )

    parser.add_argument(
        '--critical-query',
        help='`SELECT` statement. If its result contains more than one column, the number of rows '
        'is checked against `--critical`, otherwise the single value is used.',
        dest='CRITICAL_QUERY',
        default='',
    )

    parser.add_argument(
        '--delimiter',
        help='CSV delimiter. Default: `"%(default)s"`.',
        dest='DELIMITER',
        default=DEFAULT_DELIMITER,
    )

    parser.add_argument(
        '--filename',
        help='Path to CSV file. Mutually exclusive with --url.',
        dest='FILENAME',
        type=str,
    )

    parser.add_argument(
        '--insecure',
        help=lib.args.help('--insecure'),
        dest='INSECURE',
        action='store_true',
        default=DEFAULT_INSECURE,
    )

    parser.add_argument(
        '--newline',
        help='CSV newline. When reading input from the CSV, if newline is `None`, universal '
        'newlines mode is enabled. Lines in the input can end in `"\n"`, `"\r"`, or `"\r\n"`, '
        'and these are translated into `"\n"` before being returned to this plugin. If it is '
        '`""`, universal newlines mode is enabled, but line endings are returned to this '
        'plugin untranslated. If it has any of the other legal values, input lines are only '
        'terminated by the given string, and the line ending is returned to this plugin '
        'untranslated. '
        'Default: %(default)s',
        dest='NEWLINE',
        default=DEFAULT_NEWLINE,
    )

    parser.add_argument(
        '--no-proxy',
        help=lib.args.help('--no-proxy'),
        dest='NO_PROXY',
        action='store_true',
        default=DEFAULT_NO_PROXY,
    )

    parser.add_argument(
        '--password',
        help='SMB or HTTP Basic Auth Password.',
        dest='PASSWORD',
    )

    parser.add_argument(
        '--quotechar',
        help='CSV quotechar. Default: `%(default)s`.',
        dest='QUOTECHAR',
        default=DEFAULT_QUOTECHAR,
    )

    parser.add_argument(
        '--skip-header',
        help='Treat the first row as header names, and skip this row. '
        'Default: %(default)s',
        dest='SKIP_HEADER',
        action='store_true',
        default=DEFAULT_SKIP_HEADER,
    )

    parser.add_argument(
        '--timeout',
        help=lib.args.help('--timeout') + ' Default: %(default)s (seconds)',
        dest='TIMEOUT',
        type=int,
        default=DEFAULT_TIMEOUT,
    )

    parser.add_argument(
        '-u',
        '--url',
        help='URL of the CSV file, either starting with "http://", "https://" or '
        '"smb://". This is mutually exclusive with --filename.',
        dest='URL',
        type=str,
    )

    parser.add_argument(
        '--username',
        help='SMB or HTTP Basic Auth Username.',
        dest='USERNAME',
    )

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold. Supports ranges.',
        dest='WARN',
    )

    parser.add_argument(
        '--warning-query',
        help='`SELECT` statement. If its result contains more than one column, the number of rows '
        'is checked against `--warning`, otherwise the single value is used.',
        dest='WARNING_QUERY',
        default='',
    )

    args, _ = parser.parse_known_args()
    return args


def get_state_and_value(conn, query, threshold, _type):
    """Execute SQL query, get the value and check against the threshold.
    * One row, one column: Check this single value.
    * x rows: Check the number of rows against the threshold.
    """
    state = STATE_OK
    value = 0
    result = []
    shortened = False
    if query:
        result = lib.base.coe(lib.db_sqlite.select(conn, query))
        if result:
            if len(result) == 1 and len(result[0]) == 1:
                # one row, one column: could be a "select count(*) from ..." result
                value = next(iter(result[0].values()))
            else:
                # a bunch of rows (at least one) with multiple columns, so count them
                value = len(result)
                # shorten the result if there are too many rows
                if len(result) > 10:
                    # shorten the result
                    result = result[0:5] + result[-5:]
                    shortened = True

            if _type == 'warn':
                state = lib.base.get_state(value, threshold, None, _operator='range')
            else:
                state = lib.base.get_state(value, None, threshold, _operator='range')

    return state, value, result, shortened


def main():
    """The main function. This is where the magic happens."""

    # parse the command line
    try:
        args = parse_args()
    except SystemExit:
        sys.exit(STATE_UNKNOWN)

    if args.WARNING_QUERY == '' and args.CRITICAL_QUERY == '':
        lib.base.cu('Nothing to check, no queries provided.')

    # fetch data in various ways (file, url, smb)
    if args.FILENAME and args.URL:
        lib.base.oao(
            'The --filename and -u / --url parameter are mutually exclusive. '
            'Please only use one.',
            STATE_UNKNOWN,
        )

    if args.FILENAME:
        db_filename = lib.db_sqlite.__sha1sum(
            args.FILENAME + args.WARNING_QUERY + args.CRITICAL_QUERY
        )

    if args.URL:
        db_filename = lib.db_sqlite.__sha1sum(
            args.URL + args.WARNING_QUERY + args.CRITICAL_QUERY
        )
        split_url = args.URL.split('://')
        if len(split_url) != 2:
            lib.base.oao(
                f'Could not parse the protocol of the url "{args.URL}".',
                STATE_UNKNOWN,
            )
        proto, url = split_url
        if proto not in ['http', 'https', 'smb']:
            lib.base.cu(f'The protocol "{proto}" is not supported.')

        if proto in ['http', 'https']:
            header = {}
            if args.USERNAME and args.PASSWORD:
                auth = f'{args.USERNAME}:{args.PASSWORD}'
                encoded_auth = lib.txt.to_text(base64.b64encode(lib.txt.to_bytes(auth)))
                header['Authorization'] = f'Basic {encoded_auth}'
            csv = lib.base.coe(
                lib.url.fetch(
                    args.URL,
                    header=header,
                    insecure=args.INSECURE,
                    no_proxy=args.NO_PROXY,
                    timeout=args.TIMEOUT,
                )
            )
        else:
            if not HAVE_SMB:
                lib.base.oao(
                    f'Python module "{missing_lib}" is not installed.',
                    STATE_UNKNOWN,
                )
            with lib.base.coe(
                lib.smb.open_file(url, args.USERNAME, args.PASSWORD, args.TIMEOUT)
            ) as fd:
                csv = lib.txt.to_text(fd.read())

        args.FILENAME = lib.base.coe(
            lib.db_sqlite.get_db_path(
                filename=f'linuxfabrik-monitoring-plugins-csv-values-{db_filename}.csv',
            )
        )
        lib.base.coe(lib.disk.write_file(args.FILENAME, csv))

    # create the db file and import data
    conn = lib.base.coe(
        lib.db_sqlite.connect(
            filename=f'linuxfabrik-monitoring-plugins-csv-values-{db_filename}.db',
        )
    )
    lib.base.coe(
        lib.db_sqlite.import_csv(
            conn,
            args.FILENAME,
            fieldnames=args.COLUMNS_QUERY,
            skip_header=args.SKIP_HEADER,
            delimiter=args.DELIMITER,
            quotechar=args.QUOTECHAR,
            newline=args.NEWLINE,
            chunksize=args.CHUNKSIZE,
        )
    )

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

    # analyze data
    state_warn, cnt_warn, result_warn, shortened_warn = get_state_and_value(
        conn,
        args.WARNING_QUERY,
        args.WARN,
        'warn',
    )
    state = lib.base.get_worst(state, state_warn)
    state_crit, cnt_crit, result_crit, shortened_crit = get_state_and_value(
        conn,
        args.CRITICAL_QUERY,
        args.CRIT,
        'crit',
    )

    lib.db_sqlite.close(conn)
    state = lib.base.get_worst(state, state_crit)

    # build the message
    if args.WARNING_QUERY:
        msg = (
            f'{cnt_warn}'
            f' {lib.txt.pluralize("result", cnt_warn)}'
            f' from warning query `{args.WARNING_QUERY}`'
            f'{lib.base.state2str(state_warn, prefix=" ")}'
        )
    if args.WARNING_QUERY and args.CRITICAL_QUERY:
        msg += ' and '
    if args.CRITICAL_QUERY:
        msg += (
            f'{cnt_crit}'
            f' {lib.txt.pluralize("result", cnt_crit)}'
            f' from critical query `{args.CRITICAL_QUERY}`'
            f'{lib.base.state2str(state_crit, prefix=" ")}'
        )
    msg += '\n'

    if shortened_warn:
        msg += '\nAttention: Table below is truncated, showing the 5 first and the 5 last items.\n'
    try:
        keys = result_warn[0].keys()
        headers = keys
        msg += '\n' + lib.base.get_table(result_warn, keys, header=headers)
    except Exception:
        # no results
        pass

    if shortened_crit:
        msg += '\nAttention: Table below is truncated, showing the 5 first and the 5 last items.\n'
    try:
        keys = result_crit[0].keys()
        headers = keys
        msg += '\n' + lib.base.get_table(result_crit, keys, header=headers)
    except Exception:
        # no results
        pass

    perfdata += lib.base.get_perfdata(
        'cnt_warn',
        cnt_warn,
        warn=args.WARN,
    )
    perfdata += lib.base.get_perfdata(
        'cnt_crit',
        cnt_crit,
        crit=args.CRIT,
    )

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


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