#!/usr/bin/python
# -*- encoding: utf-8; py-indent-offset: 4 -*-
# +------------------------------------------------------------------+
# |             ____ _               _        __  __ _  __           |
# |            / ___| |__   ___  ___| | __   |  \/  | |/ /           |
# |           | |   | '_ \ / _ \/ __| |/ /   | |\/| | ' /            |
# |           | |___| | | |  __/ (__|   <    | |  | | . \            |
# |            \____|_| |_|\___|\___|_|\_\___|_|  |_|_|\_\           |
# |                                                                  |
# | Copyright Mathias Kettner 2014             mk@mathias-kettner.de |
# +------------------------------------------------------------------+
#
# This file is part of Check_MK.
# The official homepage is at http://mathias-kettner.de/check_mk.
#
# check_mk is free software;  you can redistribute it and/or modify it
# under the  terms of the  GNU General Public License  as published by
# the Free Software Foundation in version 2.  check_mk is  distributed
# in the hope that it will be useful, but WITHOUT ANY WARRANTY;  with-
# out even the implied warranty of  MERCHANTABILITY  or  FITNESS FOR A
# PARTICULAR PURPOSE. See the  GNU General Public License for more de-
# tails. You should have  received  a copy of the  GNU  General Public
# License along with GNU Make; see the file  COPYING.  If  not,  write
# to the Free Software Foundation, Inc., 51 Franklin St,  Fifth Floor,
# Boston, MA 02110-1301 USA.

# Example output:
# <<<fileinfo:sep(124)>>>
# 12968175080
# M:\check_mk.ini|missing
# M:\check_mk.ini|1390|12968174867
# M:\check_mk_agent.cc|86277|12968174554
# M:\Makefile|1820|12964010975
# M:\check_mk_agent.exe|102912|12968174364
# M:\crash.cc|1672|12964010975
# M:\crash.exe|20024|12968154426

# <<<fileinfo:sep(124)>>>
# 12968175080
# FILENAME|not readable|12968154426

# Parameters
# "minsize" : ( 5000,  4000 ),  in bytes
# "maxsize" : ( 8000,  9000 ),  in bytes
# "minage"  : ( 600,  1200 ),  in seconds
# "maxage"  : ( 6000, 12000 ), in seconds
import collections

fileinfo_groups = []


def _get_field(row, index, data_type):
    try:
        return data_type(row[index])
    except (IndexError, ValueError, TypeError):
        return None


def _parse_backwards_compatible(info):
    for row in info[1:]:
        name = row[0]
        # endswith("No such file...") is needed to
        # support the very old solaris perl based version of fileinfo
        if not name or name.endswith("No such file or directory"):
            continue
        ambiguous = _get_field(row, 1, str)
        missing = ambiguous == 'missing'
        failed = ambiguous in ('not readable', '')
        size = _get_field(row, 1, int)
        time = _get_field(row, 2, int)

        yield name, missing, failed, size, time


def parse_fileinfo(info):
    if not info:
        return {}
    parsed = {'files': {}}

    parsed['reftime'] = _get_field(info[0], 0, int)

    FileinfoItem = collections.namedtuple("FileinfoItem", "name missing failed size time")

    # deal with old agents' output:
    if len(info) > 1 and _get_field(info[1], 0, str) != '[[[header]]]':
        for args in _parse_backwards_compatible(info):
            fi = FileinfoItem(*args)
            parsed['files'][fi.name] = fi
        return parsed

    if len(info) < 3:
        return parsed

    header = info[2]
    name_i = header.index('name') if 'name' in header else None
    size_i = header.index('size') if 'size' in header else None
    time_i = header.index('time') if 'time' in header else None
    stat_i = header.index('status') if 'status' in header else None

    for row in info[4:]:
        name = _get_field(row, name_i, str)
        stat = _get_field(row, stat_i, str)
        size = _get_field(row, size_i, int)
        time = _get_field(row, time_i, int)

        if name is None or stat is None:
            continue

        fi = FileinfoItem(name, ('missing' in stat), ('stat failed' in stat), size, time)
        parsed['files'][fi.name] = fi

    return parsed


def fileinfo_groups_get_group_name(group_patterns, filename, reftime):
    found_these_groups = {}
    for group_name, pattern in group_patterns:
        if isinstance(pattern, str):  # support old format
            pattern = (pattern, '')

        inclusion, exclusion = pattern
        inclusion_is_regex = False
        exclusion_is_regex = False

        if inclusion.startswith("~"):
            inclusion_is_regex = True
            inclusion = inclusion[1:]
        if exclusion.startswith("~"):
            exclusion_is_regex = True
            exclusion = exclusion[1:]

        inclusion_tmp = fileinfo_process_date(inclusion, reftime)
        if inclusion != inclusion_tmp:
            inclusion = inclusion_tmp

        matches = []
        num_perc_s = 0
        if inclusion_is_regex:
            incl_match = regex(inclusion).match(filename)
            if incl_match:
                num_perc_s = group_name.count("%s")
                matches = [g and g or "" for g in incl_match.groups()]
        else:
            incl_match = fnmatch.fnmatch(filename, inclusion)

        if exclusion_is_regex:
            excl_match = regex(exclusion).match(filename)
        else:
            excl_match = fnmatch.fnmatch(filename, exclusion)

        if len(matches) < num_perc_s:
            raise MKGeneralException(
                "Invalid entry in inventory_fileinfo_groups: "
                "group name '%s' contains %d times '%%s', but regular expression "
                "'%s' contains only %d subexpression(s)." %
                (group_name, num_perc_s, inclusion, len(matches)))

        this_group_name = None
        if incl_match and not excl_match:
            if matches:
                for nr, group in enumerate(matches):
                    inclusion = instantiate_regex_pattern_once(inclusion, group)
                    group_name = group_name.replace("%%%d" % (nr + 1), group)

                this_group_name = group_name % tuple(matches[:num_perc_s])
                this_pattern = ("~%s" % inclusion, exclusion)

            else:
                this_group_name = group_name
                this_pattern = pattern

        if this_group_name is not None:
            found_these_groups.setdefault(this_group_name, set())
            found_these_groups[this_group_name].add(this_pattern)

    # Convert pattern containers to lists (sets are not possible in autochecks)
    return dict([(k, list(v)) for k, v in found_these_groups.items()])


def inventory_fileinfo_common(parsed, case):
    inventory = []

    reftime = parsed.get('reftime')
    if reftime is None:
        return inventory

    inventory_groups = host_extra_conf(host_name(), fileinfo_groups)

    for item in parsed['files'].itervalues():
        found_groups = {}
        for group_patterns in inventory_groups:
            found_groups.update(fileinfo_groups_get_group_name(group_patterns, item.name, reftime))

        if not found_groups and case == 'single' and not item.missing:
            inventory.append((item.name, {}))

        elif found_groups and case == 'group':
            for group_name, patterns in found_groups.items():
                inventory.append((group_name, {"group_patterns": patterns}))

    return inventory


def fileinfo_process_date(pattern, reftime):
    for what, the_time in [("DATE", reftime), ("YESTERDAY", reftime - 86400)]:
        the_regex = r'((?:/|[A-Za-z]).*)\$%s:((?:%%\w.?){1,})\$(.*)' % what
        disect = re.match(the_regex, pattern)
        if disect:
            prefix = disect.group(1)
            datepattern = time.strftime(disect.group(2), time.localtime(the_time))
            postfix = disect.group(3)
            pattern = prefix + datepattern + postfix
            return pattern
    return pattern


def fileinfo_check_timeranges(params):
    ranges = params.get("timeofday")
    if ranges is None:
        return ""

    now = time.localtime()
    for range_spec in ranges:
        if fileinfo_in_timerange(now, *range_spec):
            return ""
    return "Out of relevant time of day"


def fileinfo_in_timerange(now, range_from, range_to):
    minutes_from = range_from[0] * 60 + range_from[1]
    minutes_to = range_to[0] * 60 + range_to[1]
    minutes_now = now.tm_hour * 60 + now.tm_min
    return minutes_now >= minutes_from and minutes_now < minutes_to


def check_fileinfo(item, params, parsed):

    reftime = parsed.get('reftime')
    if reftime is None:
        return 3, "Missing reference timestamp"

    outof_range_txt = fileinfo_check_timeranges(params)

    file_stat = parsed['files'].get(item)
    if file_stat is not None and not file_stat.missing:
        if file_stat.failed:
            return 1, "File stat failed"
        if file_stat.time is None:
            return 1, 'File stat time failed'
        age = reftime - file_stat.time
        check_definition = [
            ("Size", "size", file_stat.size, get_filesize_human_readable),
            ("Age", "age", age, get_age_human_readable),
        ]
        return _fileinfo_check_function(check_definition, params, outof_range_txt)
    else:
        if outof_range_txt:
            return 0, "File not found - %s" % outof_range_txt
        return params.get("state_missing", 3), "File not found"


# FIXME the following does not apply anymore, because "%s" is allowed in group_name
# WHAT TO DO WITH precompile?
# Extracts patterns that are relevant for the current host and item.
# Constructs simple list of patterns and makes them available for the check
def fileinfo_groups_precompile(hostname, item, params):
    patterns = []
    for line in host_extra_conf(hostname, fileinfo_groups):
        for group_name_pattern, pattern in line:
            if group_name_pattern == item:
                patterns.append(pattern)

    from cmk_base.checking import determine_check_params
    precomped = determine_check_params(params).copy()
    precomped['precompiled_patterns'] = patterns
    return precomped


def _filename_matches(filename, reftime, inclusion, exclusion):
    date_inclusion = ""
    inclusion_is_regex = False
    exclusion_is_regex = False

    if inclusion.startswith("~"):
        inclusion_is_regex = True
        inclusion = inclusion[1:]
    if exclusion.startswith("~"):
        exclusion_is_regex = True
        exclusion = exclusion[1:]

    inclusion_tmp = fileinfo_process_date(inclusion, reftime)
    if inclusion != inclusion_tmp:
        inclusion = inclusion_tmp
        date_inclusion = inclusion_tmp

    if inclusion_is_regex:
        incl_match = regex(inclusion).match(filename)
    else:
        incl_match = fnmatch.fnmatch(filename, inclusion)

    if exclusion_is_regex:
        excl_match = regex(exclusion).match(filename)
    else:
        excl_match = fnmatch.fnmatch(filename, exclusion)
    return incl_match and not excl_match, date_inclusion


def check_fileinfo_groups(_item, params, parsed):

    outof_range_txt = fileinfo_check_timeranges(params)
    count_all = 0
    age_oldest = None
    age_newest = 0
    size_all = 0
    size_smallest = None
    size_largest = 0
    date_inclusion = ""
    include_patterns = set()
    exclude_patterns = set()
    files_stat_failed = set()
    files_matching = {}

    reftime = parsed.get('reftime')
    if reftime is None:
        yield 3, "Missing reference timestamp"
        return

    # Old format does not support '%s' in group name
    group_patterns = set(params.get('precompiled_patterns', []))
    for entry in params.get('group_patterns', []):
        group_patterns.add(entry)

    # Start counting values on all files
    for file_stat in parsed['files'].itervalues():

        for pattern in group_patterns:
            if isinstance(pattern, str):  # support old format
                pattern = (pattern, '')
            inclusion, exclusion = pattern
            include_patterns.add(inclusion)
            exclude_patterns.add(exclusion)

            filename_matches, date_inclusion = _filename_matches(file_stat.name, reftime, inclusion,
                                                                 exclusion)
            if not filename_matches:
                continue
            if file_stat.missing:
                continue
            if file_stat.failed:
                files_stat_failed.add(file_stat.name)
                continue

            size_all += file_stat.size
            if size_smallest is None:
                size_smallest = file_stat.size
            else:
                size_smallest = min(size_smallest, file_stat.size)
            size_largest = max(size_largest, file_stat.size)

            age = reftime - file_stat.time
            if age_oldest is None:  # very first match
                age_oldest = age
                age_newest = age
            else:
                age_oldest = max(age_oldest, age)
                age_newest = min(age_newest, age)
            count_all += 1

            # Used for long ouput information
            for key, value in [
                ("age_oldest", age),
                ("age_newest", age),
                ("size_smallest", file_stat.size),
                ("size_largest", file_stat.size),
                ("size", file_stat.size),
            ]:
                skip_ok_files = params.get('shorten_multiline_output', False)
                for state, _, __, ___ in _fileinfo_check_file(value, params, key):
                    if skip_ok_files and not state:
                        continue
                    files_matching.setdefault(file_stat.name, {
                        "size": file_stat.size,
                        "age": age,
                        "states": [],
                    })["states"].append(state)

    if age_oldest is None:
        age_oldest = 0

    # Start Checking
    check_definition = [
        ("Count", "count", count_all, saveint),
        ("Size", "size", size_all, get_filesize_human_readable),
        ("Largest size", "size_largest", size_largest, get_filesize_human_readable),
        ("Smallest size", "size_smallest", size_smallest, get_filesize_human_readable),
        ("Oldest age", "age_oldest", age_oldest, get_age_human_readable),
        ("Newest age", "age_newest", age_newest, get_age_human_readable),
        ("Date pattern", "date pattern", date_inclusion, str),
    ]

    if files_stat_failed:
        yield 1, "Files with unknown stat: %s" % ", ".join(files_stat_failed)

    yield _fileinfo_check_function(check_definition, params, outof_range_txt)

    for res in _fileinfo_check_conjunctions(check_definition, params):
        yield res

    long_output = []
    if any(include_patterns):
        long_output.append("Include patterns: %s" % ", ".join(include_patterns))

    if any(exclude_patterns):
        long_output.append("Exclude patterns: %s" % ", ".join(exclude_patterns))

    for filename, attrs in sorted(files_matching.iteritems(), key=lambda x: x[0]):
        long_output.append("[%s] Age: %s, Size: %s%s" % \
                          (filename, get_age_human_readable(attrs["age"]),
                           get_filesize_human_readable(attrs["size"]),
                           state_markers[max(attrs["states"])]))

    if long_output:
        yield 0, "\n%s" % "\n".join(long_output)


def _fileinfo_check_function(check_definition, params, outof_range_txt):
    infotexts = []
    states = [0]
    perfdata = []
    for title, key, value, verbfunc in check_definition:
        if value in [None, ""]:
            continue
        infotext = "%s: %s" % (title, verbfunc(value))
        warn, crit = None, None
        for state, warn, crit, comp_text in _fileinfo_check_file(value, params, key):
            if not state:
                continue
            infotext += " (warn/crit %s %s/%s)" % (comp_text, verbfunc(warn), verbfunc(crit))
            states.append(state)
        infotexts.append(infotext)
        perfdata.append((key, value, warn, crit))

    if outof_range_txt:
        states = [0]
        infotexts = [outof_range_txt] + infotexts
    return max(states), ", ".join(infotexts), perfdata


def _fileinfo_check_file(value, params, key):
    if not isinstance(value, (long, int)):
        # because strings go into infos but not into perfdata
        return []
    results = []
    for how, comp_text, cfunc in [
        ("min", "below", lambda a, b: a < b),
        ("max", "at", lambda a, b: a > b),
    ]:
        state = 0
        warn, crit = params.get(how + key, (None, None))
        if crit is not None and cfunc(value, crit):
            state = 2
        elif warn is not None and cfunc(value, warn):
            state = 1
        results.append((state, warn, crit, comp_text))
    return results


def inventory_fileinfo(parsed):
    return inventory_fileinfo_common(parsed, "single")


def _fileinfo_check_conjunctions(check_definition, params):
    conjunctions = params.get("conjunctions", [])
    for conjunction_state, levels in conjunctions:
        levels = dict(levels)
        match_texts = []
        matches = 0
        for title, key, value, readable_f in check_definition:
            level = levels.get(key)
            if level is not None and value >= level:
                match_texts.append("%s at %s" % (title.lower(), readable_f(level)))
                matches += 1

            level_lower = levels.get("%s_lower" % key)
            if level_lower is not None and value < level_lower:
                match_texts.append("%s below %s" % (title.lower(), readable_f(level_lower)))
                matches += 1

        if matches == len(levels):
            yield conjunction_state, "Conjunction: %s" % " AND ".join(match_texts)


check_info["fileinfo"] = {
    "parse_function": parse_fileinfo,
    "check_function": check_fileinfo,
    "inventory_function": inventory_fileinfo,
    "service_description": "File %s",
    "has_perfdata": True,
    "group": "fileinfo",
}


def inventory_fileinfo_groups(parsed):
    return inventory_fileinfo_common(parsed, "group")


check_info['fileinfo.groups'] = {
    "check_function": check_fileinfo_groups,
    "inventory_function": inventory_fileinfo_groups,
    "service_description": "File group %s",
    "has_perfdata": True,
    "group": "fileinfo-groups",
    "includes": ["eval_regex.include"],
}

precompile_params['fileinfo.groups'] = fileinfo_groups_precompile
