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

import lib.args
import lib.base
import lib.db_sqlite
import lib.disk
import lib.icinga
import lib.time
import lib.txt
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN

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

DESCRIPTION = """Scans a logfile for matching patterns or regular expressions and alerts based on the
number of matches found. Reads the file backwards from the end and supports Icinga
acknowledgement integration to suppress repeated alerts for known issues. Configurable
alarm duration limits how long matches trigger alerts.

`--filename` accepts time macros, so logfiles whose name contains the current date
(`20260422.log`, `app-2026-04-22.log`, etc.) can be monitored directly. `{today}` /
`{yesterday}` resolve tolerantly: compact (`YYYYMMDD`) first, ISO 8601 (`YYYY-MM-DD`)
as fallback if the compact file does not exist. Read offset and pending matches carry
over when the filename changes on the next day, no wrapper script needed.

Requires root or sudo."""

DEFAULT_ALARM_DURATION = 60  # minutes (1 hour)
DEFAULT_CRIT = 1
DEFAULT_ICINGA_CALLBACK = False
DEFAULT_INSECURE = True
DEFAULT_NO_PROXY = False
DEFAULT_NO_SUMMARY = False
DEFAULT_TIMEOUT = 5
DEFAULT_WARN = 1


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(
        '--alarm-duration',
        help='Duration in minutes for how long new matches trigger an alert. '
        'Overwritten by `--icinga-callback`. '
        'Default: %(default)s',
        dest='ALARM_DURATION',
        type=int,
        default=DEFAULT_ALARM_DURATION,
    )

    parser.add_argument(
        '--always-ok',
        help=lib.args.help('--always-ok'),
        dest='ALWAYS_OK',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '-c',
        '--critical',
        help='CRIT threshold for the number of found critical matches. '
        'Default: %(default)s',
        dest='CRIT',
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--critical-pattern',
        help='Any line containing this pattern will count as a critical. '
        'Can be specified multiple times.',
        action='append',
        default=None,
        dest='CRIT_PATTERN',
    )

    parser.add_argument(
        '--critical-regex',
        help='Any line matching this Python regex will count as a critical. '
        'Can be specified multiple times.',
        action='append',
        dest='CRIT_REGEX',
        default=None,
    )

    parser.add_argument(
        '--filename',
        help='Path to the logfile. '
        'Supports time macros that are expanded on every run: '
        '`{today}` / `{yesterday}` first try the compact form `YYYYMMDD`, '
        'then fall back to `YYYY-MM-DD` if that file does not exist. '
        '`{%%Y}`, `{%%y}`, `{%%m}`, `{%%d}`, `{%%H}`, `{%%M}`, `{%%S}` '
        'render the matching strftime component of the current time. '
        'Example: `/var/log/app/{today}.log`. '
        'Example: `/var/log/app/app-{today}.log`. '
        'Example: `/var/log/app/{%%Y}{%%m}{%%d}.log`.',
        dest='FILENAME',
        required=True,
        type=str,
    )

    parser.add_argument(
        '--icinga-callback',
        help='Get the service acknowledgement from Icinga. '
        'Overwrites `--alarm-duration`. '
        'Default: %(default)s',
        dest='ICINGA_CALLBACK',
        action='store_true',
        default=DEFAULT_ICINGA_CALLBACK,
    )

    parser.add_argument(
        '--icinga-password',
        help='Icinga API password.',
        dest='ICINGA_PASSWORD',
    )

    parser.add_argument(
        '--icinga-service-name',
        help='Unique name of the service using this check within Icinga. '
        'Take it from the `__name` service attribute. '
        'Example: `icinga-server!my-service-name`.',
        dest='ICINGA_SERVICE_NAME',
    )

    parser.add_argument(
        '--icinga-url',
        help='Icinga API URL. Example: `https://icinga-server:5665`.',
        dest='ICINGA_URL',
    )

    parser.add_argument(
        '--icinga-username',
        help='Icinga API username.',
        dest='ICINGA_USERNAME',
    )

    parser.add_argument(
        '--ignore-pattern',
        help='Any line containing this pattern will be ignored. '
        'Case-sensitive. '
        'Can be specified multiple times.',
        action='append',
        default=None,
        dest='IGNORE_PATTERN',
    )

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

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

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

    parser.add_argument(
        '--suppress-lines',
        help='Suppress the found lines in the output and only report the number of findings.',
        dest='SUPPRESS_OUTPUT',
        action='store_true',
        default=False,
    )

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

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for the number of found warning matches. '
        'Default: %(default)s',
        dest='WARN',
        default=DEFAULT_WARN,
    )

    parser.add_argument(
        '--warning-pattern',
        help='Any line containing this pattern will count as a warning. '
        'Can be specified multiple times.',
        action='append',
        default=None,
        dest='WARN_PATTERN',
    )

    parser.add_argument(
        '--warning-regex',
        help='Any line matching this Python regex will count as a warning. '
        'Can be specified multiple times.',
        action='append',
        dest='WARN_REGEX',
        default=None,
    )

    args, _ = parser.parse_known_args()
    return args


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.CRIT_PATTERN is None:
        args.CRIT_PATTERN = []
    if args.CRIT_REGEX is None:
        args.CRIT_REGEX = []
    if args.IGNORE_PATTERN is None:
        args.IGNORE_PATTERN = []
    if args.IGNORE_REGEX is None:
        args.IGNORE_REGEX = []
    if args.WARN_PATTERN is None:
        args.WARN_PATTERN = []
    if args.WARN_REGEX is None:
        args.WARN_REGEX = []

    # Expand time macros for filesystem access only. args.FILENAME stays the
    # DB key so offset and pending matches survive the daily filename change
    # (issue #678). For {today} / {yesterday}, try the compact form
    # (`20260422`) first because that is what most rotated logfiles use, and
    # fall back to ISO 8601 (`2026-04-22`) if no compact file exists.
    scan_path = lib.time.macro2timestr(args.FILENAME, format='%Y%m%d')
    if not os.path.exists(scan_path):
        iso_path = lib.time.macro2timestr(args.FILENAME, format='%Y-%m-%d')
        if iso_path != scan_path and os.path.exists(iso_path):
            scan_path = iso_path

    try:
        file_stat = os.stat(scan_path)
    except FileNotFoundError as e:
        # no traceback wanted, so using oao() instead of cu()
        lib.base.oao(f'{e.strerror}: "{scan_path}"', STATE_UNKNOWN)

    if not any(
        (args.WARN_PATTERN, args.WARN_REGEX, args.CRIT_PATTERN, args.CRIT_REGEX)
    ):
        lib.base.cu('At least one pattern or regex is required.')

    if args.ICINGA_CALLBACK and not all(
        (
            args.ICINGA_URL,
            args.ICINGA_PASSWORD,
            args.ICINGA_USERNAME,
            args.ICINGA_SERVICE_NAME,
        )
    ):
        lib.base.cu(
            '`--icinga-callback` requires `--icinga-url`, `--icinga-password`, `--icinga-username` and `--icinga-service-name`.'
        )

    # Derive a short, stable hash over every argument that influences which
    # lines this check flags. This is used to give each unique combination
    # of patterns/regexes its own state DB, so two Icinga services on the
    # same logfile with different patterns no longer trample each other's
    # read offset or match history (issue #698).
    instance_payload = json.dumps(
        {
            'crit_pattern': sorted(args.CRIT_PATTERN),
            'crit_regex': sorted(args.CRIT_REGEX),
            'ignore_pattern': sorted(args.IGNORE_PATTERN),
            'ignore_regex': sorted(args.IGNORE_REGEX),
            'warn_pattern': sorted(args.WARN_PATTERN),
            'warn_regex': sorted(args.WARN_REGEX),
        },
        sort_keys=True,
    ).encode('utf-8')
    instance_hash = hashlib.sha256(instance_payload).hexdigest()[:10]

    basename = os.path.basename(args.FILENAME)
    db_filename = (
        f'linuxfabrik-monitoring-plugins-logfile-{basename}-{instance_hash}.db'
    )

    # Transparent migration for deployments that ran exactly one check per
    # logfile before this change: rename the legacy per-file DB to the new
    # per-check name so the existing offset and pending matches survive.
    tmpdir = lib.disk.get_tmpdir()
    legacy_db_path = os.path.join(
        tmpdir,
        f'linuxfabrik-monitoring-plugins-logfile-{basename}.db',
    )
    new_db_path = os.path.join(tmpdir, db_filename)
    if os.path.exists(legacy_db_path) and not os.path.exists(new_db_path):
        try:
            os.rename(legacy_db_path, new_db_path)
        except OSError:
            pass

    # create the db table
    conn = lib.base.coe(
        lib.db_sqlite.connect(
            filename=db_filename,
        )
    )
    definition = """
        filename TEXT NOT NULL PRIMARY KEY,
        offset INTEGER NOT NULL,
        inode TEXT NOT NULL
    """
    lib.base.coe(lib.db_sqlite.create_table(conn, definition, table='file_stats'))
    definition = """
        filename TEXT NOT NULL,
        state INTEGER NOT NULL,
        line TEXT NOT NULL,
        found_at TIMESTAMP NOT NULL
    """
    lib.base.coe(lib.db_sqlite.create_table(conn, definition, table='matching_lines'))

    current_inode = str(file_stat.st_ino)
    current_size = file_stat.st_size

    old_file_stats = lib.base.coe(
        lib.db_sqlite.select(
            conn,
            """
        SELECT *
        FROM file_stats
        WHERE filename = :filename
        """,
            {
                'filename': args.FILENAME,
            },
            fetchone=True,
        )
    )

    if old_file_stats:
        # this is not the first time we are scanning this logfile, try to continue where we left off
        offset = old_file_stats.get('offset', 0)
        # Compare the stored inode as string: legacy DBs created before
        # issue #1035 still hold the inode column with INTEGER affinity
        # and return int on read, while current_inode is a string. Without
        # the cast the comparison is always truthy and the offset is
        # reset to 0 on every run, making the plugin re-read the whole
        # file each time.
        if str(old_file_stats.get('inode')) != current_inode or current_size < offset:
            # this means the file has been rotated
            offset = 0
    else:
        offset = 0

    compiled_warn_regex = [re.compile(item) for item in args.WARN_REGEX]
    compiled_crit_regex = [re.compile(item) for item in args.CRIT_REGEX]
    compiled_ignore_regex = [re.compile(item) for item in args.IGNORE_REGEX]

    # per-pattern counters, for the verbose "matched N lines" summary.
    # These are purely for reporting; the scan semantics (which lines are
    # classified as warn/crit) remain unchanged.
    warn_pattern_hits = dict.fromkeys(args.WARN_PATTERN, 0)
    warn_regex_hits = dict.fromkeys(args.WARN_REGEX, 0)
    crit_pattern_hits = dict.fromkeys(args.CRIT_PATTERN, 0)
    crit_regex_hits = dict.fromkeys(args.CRIT_REGEX, 0)

    warn_matches = []
    crit_matches = []
    line_counter = 0
    try:
        with open(scan_path) as logfile:
            logfile.seek(offset)
            for line in logfile:
                line_counter += 1
                is_ignored = any(
                    ignore_pattern in line for ignore_pattern in args.IGNORE_PATTERN
                ) or any(item.search(line) for item in compiled_ignore_regex)

                # due to lazy evaluation, the regex will only be executed
                # if the pattern does not match
                # see https://docs.python.org/3/reference/expressions.html#boolean-operations
                if any(
                    warn_pattern in line for warn_pattern in args.WARN_PATTERN
                ) or any(item.search(line) for item in compiled_warn_regex):
                    if not is_ignored:
                        warn_matches.append(line.strip())
                        for pat in args.WARN_PATTERN:
                            if pat in line:
                                warn_pattern_hits[pat] += 1
                        for idx, rgx in enumerate(compiled_warn_regex):
                            if rgx.search(line):
                                warn_regex_hits[args.WARN_REGEX[idx]] += 1

                if any(
                    crit_pattern in line for crit_pattern in args.CRIT_PATTERN
                ) or any(item.search(line) for item in compiled_crit_regex):
                    if not is_ignored:
                        crit_matches.append(line.strip())
                        for pat in args.CRIT_PATTERN:
                            if pat in line:
                                crit_pattern_hits[pat] += 1
                        for idx, rgx in enumerate(compiled_crit_regex):
                            if rgx.search(line):
                                crit_regex_hits[args.CRIT_REGEX[idx]] += 1
            offset = logfile.tell()
    except PermissionError:
        lib.base.cu(f"Permission denied opening '{scan_path}'.")
        # make sure conn is always closed
        lib.db_sqlite.close(conn)

    # Save the current position for the next run
    new_file_stats = {
        'filename': args.FILENAME,
        'offset': offset,
        'inode': current_inode,
    }
    lib.base.coe(lib.db_sqlite.replace(conn, new_file_stats, table='file_stats'))

    # if we are using the alarm duration (aka not using the icinga callback),
    # remove all outdated matches from the db now
    now = lib.time.now(as_type='datetime')
    if not args.ICINGA_CALLBACK:
        outdated = now - datetime.timedelta(minutes=args.ALARM_DURATION)
        lib.base.coe(
            lib.db_sqlite.delete(
                conn,
                """
            DELETE
            FROM matching_lines
            WHERE filename = :filename and found_at <= :outdated
            """,
                {'filename': args.FILENAME, 'outdated': outdated},
            )
        )

    # get the old matches and take them into consideration for the state
    # for example, if there are no new lines we still want to alarm the old ones
    # until the service is acknowledged
    old_warn_matches = lib.base.coe(
        lib.db_sqlite.select(
            conn,
            """
        SELECT *
        FROM matching_lines
        WHERE filename = :filename and state = :state
        """,
            {'filename': args.FILENAME, 'state': STATE_WARN},
            fetchone=False,
        )
    )
    old_crit_matches = lib.base.coe(
        lib.db_sqlite.select(
            conn,
            """
        SELECT *
        FROM matching_lines
        WHERE filename = :filename and state = :state
        """,
            {'filename': args.FILENAME, 'state': STATE_CRIT},
            fetchone=False,
        )
    )

    state = lib.base.get_worst(
        lib.base.get_state(len(warn_matches) + len(old_warn_matches), args.WARN, None),
        lib.base.get_state(len(crit_matches) + len(old_crit_matches), None, args.CRIT),
    )

    msg_addendum = ''
    if args.ICINGA_CALLBACK and state != STATE_OK:
        # check if the service is acknowledged
        success, icinga = lib.icinga.get_service(
            args.ICINGA_URL,
            args.ICINGA_USERNAME,
            args.ICINGA_PASSWORD,
            servicename=args.ICINGA_SERVICE_NAME,
            attrs='state,acknowledgement',
            insecure=args.INSECURE,
            no_proxy=args.NO_PROXY,
            timeout=args.TIMEOUT,
        )
        if success:
            try:
                if icinga['results'][0]['attrs']['acknowledgement']:
                    # this means the service is acknowledged
                    state = STATE_OK
                    lib.base.coe(
                        lib.db_sqlite.delete(
                            conn,
                            """
                        DELETE
                        FROM matching_lines
                        WHERE filename = :filename
                        """,
                            {'filename': args.FILENAME},
                        )
                    )
                    old_warn_matches = []
                    old_crit_matches = []
                else:
                    msg_addendum += (
                        'Note: Acknowledge this service to reset the state to OK.'
                    )
            except IndexError:
                msg_addendum += 'Note: Could not determine the acknowledgement from the Icinga API, this could be due to an incorrect service name.'
        else:
            msg_addendum += f'Note: Could not determine the acknowledgement from the Icinga API:\n{icinga}.'

    # save to db, these lines will be alarmed until the service is acknowledged in icinga2
    for match in warn_matches:
        lib.base.coe(
            lib.db_sqlite.insert(
                conn,
                {
                    'filename': args.FILENAME,
                    'state': STATE_WARN,
                    'line': match.strip(),
                    'found_at': now,
                },
                table='matching_lines',
            )
        )
    for match in crit_matches:
        lib.base.coe(
            lib.db_sqlite.insert(
                conn,
                {
                    'filename': args.FILENAME,
                    'state': STATE_CRIT,
                    'line': match.strip(),
                    'found_at': now,
                },
                table='matching_lines',
            )
        )
    lib.base.coe(lib.db_sqlite.commit(conn))
    lib.db_sqlite.close(conn)

    # build the message: name the scanned file, every configured pattern /
    # regex with its per-pattern match count, the severity label if the
    # pattern produced hits, and the ignore patterns. See issue #547.
    def _fmt_pattern(pattern, count, severity):
        label = f' [{severity}]' if count > 0 else ''
        return (
            f"'{pattern}' (matched {count} {lib.txt.pluralize('line', count)}){label}"
        )

    def _join_and(items):
        if len(items) <= 1:
            return ''.join(items)
        if len(items) == 2:
            return f'{items[0]} and {items[1]}'
        return f'{", ".join(items[:-1])} and {items[-1]}'

    pattern_parts = []
    for pat in sorted(args.WARN_PATTERN):
        pattern_parts.append(_fmt_pattern(pat, warn_pattern_hits[pat], 'WARNING'))
    for rgx in sorted(args.WARN_REGEX):
        pattern_parts.append(_fmt_pattern(rgx, warn_regex_hits[rgx], 'WARNING'))
    for pat in sorted(args.CRIT_PATTERN):
        pattern_parts.append(_fmt_pattern(pat, crit_pattern_hits[pat], 'CRITICAL'))
    for rgx in sorted(args.CRIT_REGEX):
        pattern_parts.append(_fmt_pattern(rgx, crit_regex_hits[rgx], 'CRITICAL'))

    ignore_parts = [
        f"'{item}'" for item in sorted(args.IGNORE_PATTERN + args.IGNORE_REGEX)
    ]

    msg = (
        f'Scanned {scan_path} ({line_counter} '
        f'{lib.txt.pluralize("line", line_counter)}) '
        f'using patterns {_join_and(pattern_parts)}'
    )
    if ignore_parts:
        msg += f', ignoring {_join_and(ignore_parts)}'
    msg += '.'

    if old_warn_matches:
        msg += (
            f' {len(old_warn_matches)} unacknowledged warning'
            f' {lib.txt.pluralize("match", len(old_warn_matches), "es")}'
            f' from previous runs.'
        )

    if old_crit_matches:
        msg += (
            f' {len(old_crit_matches)} unacknowledged critical'
            f' {lib.txt.pluralize("match", len(old_crit_matches), "es")}'
            f' from previous runs.'
        )

    if not args.SUPPRESS_OUTPUT:
        if warn_matches:
            msg += '\n\nWarning matches:\n* ' + '\n* '.join(warn_matches)

        if crit_matches:
            msg += '\n\nCritical matches:\n* ' + '\n* '.join(crit_matches)

        if old_warn_matches:
            msg += (
                '\n\nUnacknowledged warning matches from previous runs:\n* '
                + '\n* '.join(match['line'] for match in old_warn_matches)
            )

        if old_crit_matches:
            msg += (
                '\n\nUnacknowledged critical matches from previous runs:\n* '
                + '\n* '.join(match['line'] for match in old_crit_matches)
            )

    perfdata = lib.base.get_perfdata(
        'scanned_lines',
        line_counter,
    )
    perfdata += lib.base.get_perfdata(
        'warn_matches',
        len(warn_matches),
        warn=args.WARN,
    )
    perfdata += lib.base.get_perfdata(
        'crit_matches',
        len(crit_matches),
        warn=args.WARN,
    )

    # over and out
    lib.base.oao(msg + '\n\n' + msg_addendum, state, perfdata, always_ok=args.ALWAYS_OK)


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