"""
Module to manage lograptor applications
"""
#
# Copyright (C), 2011-2020, by SISSA - International School for Advanced Studies.
#
# This file is part of lograptor.
#
# Lograptor is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# file 'LICENSE' in the root directory of the present distribution
# for more details.
#
# @Author Davide Brunato <brunato@sissa.it>
#
import logging
import re
import string
import configparser
from collections import Counter

from .exceptions import LogRaptorConfigError, RuleMissingError, LogRaptorOptionError
from .confparsers import AppConfig
from .report import ReportData
from .utils import field_multisub, exact_sub


logger = logging.getLogger(__package__)


class AppRule(object):
    """
    Class to manage application rules. The rules are used to
    parse the log lines and to store matching results.

    Attributes:
        - name: the rule option name in the app configuration file
        - pattern : the compiled regex pattern of the rule
        - results : dictionary of rule results
        - filter_keys: the filtering keys (all regex groups connected
            to those keys must be not Non to matching a rule)
        - full_match: determine if a rule match represents a full matching
                      for the line (needed for thread matching mode)
        - used_by_report : True if is used by a report rule
        - key_gids : map from gid to result key tuple index
    """

    def __init__(self, name, pattern, app, filter_keys=None):
        """
        Initialize AppRule.

        :param name: the configuration option name
        :param pattern: the option value that represents the search pattern
        :param app: the application in which the rule is defined
        :param filter_keys: the filtering keys dictionary if the rule is a filter
        """
        try:
            if not pattern:
                raise LogRaptorConfigError('empty rule %r' % name)
            self.pattern = re.compile(pattern)
        except re.error as err:
            msg = "invalid pattern for app\'s rule {!r}: {}"
            raise LogRaptorConfigError(msg.format(name, str(err)))

        key_gids = ['host']
        for gid in self.pattern.groupindex:
            if gid != 'host':
                key_gids.append(gid)

        if not key_gids:
            raise LogRaptorConfigError("key gids set of the rule {!r} is empty!".format(name))
        self.key_gids = tuple(key_gids)

        self.name = name
        self.app = app
        self.filter_keys = filter_keys or []
        self.full_match = filter_keys is not None
        self.used_by_report = False
        self.results = Counter()
        self._last_idx = None

    def __repr__(self):
        return "%s(name=%r, app=%r)" % (self.__class__.__name__, self.name, self.app.name)

    def add_result(self, values):
        """
        Add a tuple or increment the value of an existing one
        in the rule results dictionary.
        """
        self._last_idx = tuple(values[gid] for gid in self.key_gids)
        self.results[self._last_idx] += 1

    def increase_last(self, k):
        """
        Increase the last result by k.
        """
        if self._last_idx is not None:
            self.results[self._last_idx] += k

    def total_events(self, condition, value_field=None):
        """
        Returns total number of events in the rule's result set. The *condition* selects
        the events to count. If also a *value field* is provided the function computes
        the sum taking the product of each value times the correspondent event counter.
        """
        if condition == "*" and value_field is None:
            return sum(self.results.values())

        results = self.results
        val = self.key_gids.index(value_field) if value_field is not None else None

        if condition == "*":
            tot = 0
            for key in results:
                tot += results[key] * int(key[val])
            return tot

        match = re.search(r'(\w+)(!=|==)\"([^\"]*)\"', condition)
        condition_index = self.key_gids.index(match.group(1))
        invert = (match.group(2) == '!=')
        condition_pattern = re.compile(match.group(3))

        tot = 0
        for key in results:
            match = condition_pattern.search(key[condition_index])
            if (not invert and match is not None) or (invert and match is None):
                if value_field is None:
                    tot += results[key]
                else:
                    tot += results[key] * int(key[val])
        return tot

    def top_events(self, num, value_field, usemax, gid):
        """
        Returns a list with the top *num* list of events. Each element
        contains a value, indicating the number of events, and a list of
        matching gid values (usernames, email addresses, clients).
        Instead of calculating the top sum of occurrences a *value_field*
        should be provided to compute the max of a numeric value field or
        the sum of product of value field with events.
        """
        def classify():
            if value is None:
                return

            for j in range(num):
                if top[j] is None:
                    top[j] = [tot, [value]]
                    break
                elif tot == top[j][0]:
                    top[j][1].append(value)
                    break
                elif tot > top[j][0]:
                    top.insert(j, [tot, [value]])
                    break

        if not self.results:
            return []

        results = self.results
        top = [None] * num
        pos = self.key_gids.index(gid)
        val = None

        # Compute top(max) if a value fld is provided
        if value_field is not None:
            val = self.key_gids.index(value_field)
            if usemax:
                i = 0
                for key in sorted(results.keys(), key=lambda x: (int(x[val]), x[pos]),
                                  reverse=True)[:num]:
                    top[i] = [int(key[val]), [key[pos]]]
                    i += 1
                return [res for res in top if res is not None]

        value = None
        tot = 0
        for key in sorted(results.keys(), key=lambda x: (x[pos])):
            if value is None or value != key[pos]:
                classify()
                value = key[pos]
                tot = results[key] if value_field is None else results[key] * int(key[val])
                continue
            tot += results[key] if value_field is None else results[key] * int(key[val])
        else:
            classify()

        del top[num:]
        return [res for res in top if res is not None]

    def list_events(self, cond, cols, fields):
        """
        Return the list of events, with a specific order and filtered by a condition.
        An element of the list is a tuple with three component. The first is the main
        attribute (first field). The second the second field/label, usually a string
        that identify the service. The third is a dictionary with a key-tuple composed
        by all other fields and values indicating the number of events associated.
        """

        def insert_row():
            """
            Internal function to flush results for a single tabkey to result list.
            """
            row = list(row_template)
            j = 0
            for n in range(cols):
                if row[n] is None:
                    if j == keylen:
                        row[n] = tabvalues
                    else:
                        row[n] = tabkey[j]
                    j += 1
            reslist.append(row)

        if not self.results:
            return []

        # Set local variables
        results = self.results
        pos = [self.key_gids.index(gid) for gid in fields if gid[0] != '"']
        has_cond = cond != "*"

        # If a condition is passed then compile a pattern matching object
        if has_cond:
            match = re.search(r'(\w+)(!=|==)\"([^\"]*)\"', cond)
            condpos = self.key_gids.index(match.group(1))
            invert = (match.group(2) == '!=')
            recond = re.compile(match.group(3))
        else:
            recond = condpos = invert = None

        # Define the row template with places for values and fixed strings
        row_template = []
        for i in range(cols):
            if fields[i][0] == '"':
                row_template.append(fields[i].strip('"'))
            else:
                row_template.append(None)

        # Set the processing table and reduced key length
        keylen = len(pos) - (len(fields) - cols) - 1
        tabvalues = dict()
        tabkey = None

        reslist = []
        for key in sorted(results, key=lambda x: x[pos[0]]):
            # Skip results that don't satisfy the condition
            if has_cond:
                try:
                    match = recond.search(key[condpos])
                except TypeError:
                    continue
                if ((match is None) and not invert) or ((match is not None) and invert):
                    continue

            new_tabkey = [key[pos[i]] for i in range(keylen)]
            if tabkey is None:
                tabkey = new_tabkey
            elif tabkey != new_tabkey:
                insert_row()
                tabvalues = dict()
                tabkey = [key[pos[i]] for i in range(keylen)]

            value = tuple([key[k] for k in pos[keylen:]])
            if value in tabvalues:
                tabvalues[value] += results[key]
            else:
                tabvalues[value] = results[key]

        if tabvalues:
            insert_row()
        return reslist


class AppLogParser(object):
    """
    Class to manage application log rules and results
    """
    def __init__(self, name, cfgfile, args, logdir, fields, name_cache=None, report=None):
        """
        :param name: application name
        :param cfgfile: application config file
        :param args: cli arguments
        :param logdir: Log directory
        :param fields: Configured fields
        :param name_cache: Optional name cache (--ip-lookup/--uid-lookup/--anonymize options)
        :param report: Optional report (--report option)
        """
        logger.debug('initialize app %r', name)

        self.name = name            # Application name
        self.cfgfile = cfgfile      # App configuration file
        self.args = args
        self.logdir = logdir
        self.fields = fields
        self.name_cache = name_cache

        # Setting instance internal variables for process phase
        self._report = report
        self._thread = args.thread
        self.matches = 0            # Parsed lines counter
        self.unparsed = 0           # Unparsed lines counter
        self._last_rule = None      # Last matched rule
        self._last_idx = None       # Last index matched

        self.config = AppConfig(cfgfiles=cfgfile, appname=name, logdir=logdir)

        self.description = self.config.get('main', 'description')
        self.tags = list(set(re.split(r'\s*,\s*', self.config.get('main', 'tags'))))
        self._files = list(set(re.split(r'\s*,\s*', self.config.get('main', 'files'))))
        self.enabled = self.config.getboolean('main', 'enabled')
        self.priority = self.config.getint('main', 'priority')
        self.files = field_multisub(self._files, 'host', args.hosts or ['*'])

        logger.debug('app %r run tags: %r', name, self.tags)
        logger.debug('app %r run files: %r', name, self.files)
        logger.debug('app %r: enabled=%r, priority=%s', name, self.enabled, self.priority)

        self.rules = self.parse_rules()

        if self._report:
            subreports = [sr.name for sr in self._report.subreports]
            self.report_data = [e for e in self.get_report_data() if e.subreport in subreports]

        self.has_filters = any([rule.filter_keys for rule in self.rules])

        if self.has_filters:
            # If the app has filters, reorder rules putting the filters first.
            self.rules = sorted(self.rules, key=lambda x: x.filter_keys)
            logger.debug('filter rules of app %r: %d', name, len(self.filters))
            logger.debug('other rules of app %r: %d', name, len(self.rules) - len(self.filters))
        else:
            for rule in self.rules:
                rule.full_match = True
        logger.info('initialized app %r with %d pattern rules', name, len(self.rules))

    def __repr__(self):
        return "%s(name=%r)" % (self.__class__.__name__, self.name)

    @property
    def filters(self):
        return [rule for rule in self.rules if rule.filter_keys]

    def get_report_data(self):
        report_data = []
        for section in filter(lambda x: x not in ['main', 'rules'], self.config.sections()):
            options = self.config.items(section)
            try:
                data_item = ReportData(section, options, self.rules)
            except RuleMissingError as msg:
                logger.debug(msg)
            except LogRaptorOptionError as err:
                logger.error('skip report data %r for app %r: %s', section, self.name, err)
            else:
                report_data.append(data_item)
        return report_data

    def parse_rules(self):
        """
        Add a set of rules to the app, dividing between filter and other rule set
        """
        # Load patterns: an app is removed when has no defined patterns.
        try:
            rule_options = self.config.items('rules')
        except configparser.NoSectionError:
            raise LogRaptorConfigError("the app %r has no defined rules!" % self.name)

        rules = []
        for option, value in rule_options:
            pattern = value.replace('\n', '')  # Strip newlines for multi-line declarations
            if not self.args.filters:
                # No filters case: substitute the filter fields with the corresponding patterns.
                pattern = string.Template(pattern).safe_substitute(self.fields)
                rules.append(AppRule(option, pattern, self))
                continue

            for filter_group in self.args.filters:
                _pattern, filter_keys = exact_sub(pattern, filter_group)
                _pattern = string.Template(_pattern).safe_substitute(self.fields)
                if len(filter_keys) >= len(filter_group):
                    rules.append(AppRule(option, _pattern, self, filter_keys))
                elif self._thread:
                    rules.append(AppRule(option, _pattern, self))
        return rules

    def increase_last(self, k):
        """
        Increase the counter of the last matched rule by k.
        """
        try:
            self._last_rule.increase_last(k)
        except AttributeError:
            pass

    def match_rules(self, log_data):
        """
        Process a log line data message with app's pattern rules.
        Return a tuple with this data:

            Element #0 (app_matched): True if a rule match, False otherwise;
            Element #1 (has_full_match): True if a rule match and is a filter or the
                app has not filters; False if a rule match but is not a filter;
                None otherwise;
            Element #2 (app_thread): Thread value if a rule match and it has a "thread"
                group, None otherwise;
            Element #3 (output_data): Mapping dictionary if a rule match and a map
                of output is requested (--anonymize/--ip/--uid options).
        """
        for rule in self.rules:
            match = rule.pattern.search(log_data.message)
            if match is not None:
                gids = rule.pattern.groupindex
                self._last_rule = rule
                if self.name_cache is not None:
                    values = self.name_cache.match_to_dict(match, rule.key_gids)
                    values['host'] = self.name_cache.map_value(log_data.host, 'host')
                    output_data = {
                        'host': values['host'],
                        'message': self.name_cache.match_to_string(match, gids, values),
                    }
                else:
                    values = {'host': log_data.host}
                    for gid in gids:
                        values[gid] = match.group(gid)
                    output_data = None

                if self._thread and 'thread' in rule.pattern.groupindex:
                    thread = match.group('thread')
                    if rule.filter_keys is not None and \
                            any([values[key] is None for key in rule.filter_keys]):
                        return False, None, None, None
                    if self._report:
                        rule.add_result(values)
                    return True, rule.full_match, thread, output_data
                else:
                    if rule.filter_keys is not None and \
                            any([values[key] is None for key in rule.filter_keys]):
                        return False, None, None, None
                    elif self._report or (rule.filter_keys is not None or not self.has_filters):
                        rule.add_result(values)
                    return True, rule.full_match, None, output_data

        # No rule match: the application log message is not parsable with enabled rules.
        self._last_rule = None
        return False, None, None, None
