#
#   logactio  -  simple framework for doing configured action on certain
#                log file events
#
# SPDX-FileCopyrightText: Copyright 2012-  David Sommerseth <dazo@eurephia.org>
# SPDX-License-Identifier: GPL-2.0-only
#

import configparser
import importlib
import inotify_simple
import os
import re
import select
import signal
import sys
import threading
import time
import LogActio.ReporterQueue
from LogActio.ThresholdWatch import ThresholdWatch
from LogActio.Reporters import DefaultReporter

class WatcherThread(threading.Thread):
    def __init__(self, logfile, reporters):
        # This object will watch for changes in one particular log file
        self.__rules = []
        self.__logfile = logfile
        self.__reporters = reporters
        self.__shutdown = False
        super().__init__()


    def GetLogfile(self):
        return self.__logfile


    def AddRule(self, prefix, regex, thrtype, threshold, timeframe, ratelimit, resetrules, reporters):
        # Convert threshold type from string to known internal variables
        if thrtype is None or thrtype.lower() == "rule":
            thrtype = ThresholdWatch.WATCHTYPE_RULE
        elif thrtype.lower() == "exact":
            thrtype = ThresholdWatch.WATCHTYPE_EXACT

        # Adds a rule specific for this log file
        rule = {"prefix": prefix,
                "regex": re.compile(regex),
                "threshold": ThresholdWatch(thrtype,
                                            {"threshold": threshold,
                                             "timeframe": timeframe,
                                             "ratelimit": ratelimit}),
                "resetrules": resetrules,
                "alerts_sent": 0,
                "reporters": reporters}
        self.__rules.append(rule)


    def StartWatcher(self):
        # Start the default reporter modules
        for rep in self.__reporters:
            rep._Start()

        # Start reporter modules declared in rules
        for r in self.__rules:
            if ("reporters" in r) and (r["reporters"] is not None):
               for rep in r["reporters"]:
                   rep._Start()

        # Start the thread with this watcher
        self.start()

    def __process_line(self, line):
        resetlist = []
        for alert in self.__rules:
            m = alert["regex"].match(line.splitlines()[0])
            if not self.__shutdown and m:
                regexmatch = m.groups()
                if alert["threshold"].CheckThreshold(alert, regexmatch):
                    alert["alerts_sent"] += 1

                    rep = alert["reporters"] if (alert.get("reporters")) else self.__reporters
                    for r in rep:
                        r.ProcessEvent(self.__logfile, alert["prefix"], regexmatch,
                                       alert["threshold"].GetCurrentCount(regexmatch)+1,
                                       alert["threshold"].GetThreshold())

                    if alert["resetrules"]:
                        for r in alert["resetrules"]:
                            resetlist.append((r, regexmatch))

        for (reset_prefix, rgmatch) in resetlist:
            for rule in self.__rules:
                if rule["prefix"] == reset_prefix:
                    rule["threshold"].ClearTimeTrackers(rgmatch)


    def __drain(self, fp):
        while True:
            line = fp.readline()
            if not line:
                break
            self.__process_line(line)


    def run(self):
        fp = None
        wd_file = None
        inotify = inotify_simple.INotify()

        parent_dir = os.path.dirname(os.path.abspath(self.__logfile))
        logfile_name = os.path.basename(self.__logfile)
        file_flags = (inotify_simple.flags.MODIFY |
                      inotify_simple.flags.MOVE_SELF |
                      inotify_simple.flags.DELETE_SELF)

        try:
            fp = open(self.__logfile, "r")
            fp.seek(0, 2)
        except OSError:
            self.Shutdown()
            inotify.close()
            raise Exception(f"Could not access logfile: {self.__logfile}")

        wd_file = inotify.add_watch(self.__logfile, file_flags)
        wd_dir = inotify.add_watch(parent_dir, inotify_simple.flags.CREATE)

        try:
            while not self.__shutdown:
                # Poll with a 1-second timeout so shutdown is noticed promptly
                rlist, _, _ = select.select([inotify.fileno()], [], [], 1.0)
                if not rlist:
                    continue

                for event in inotify.read(timeout=0):
                    mask_flags = inotify_simple.flags.from_mask(event.mask)

                    if event.wd == wd_file:
                        if inotify_simple.flags.MODIFY in mask_flags:
                            # Handle in-place truncation (e.g. echo > logfile)
                            if fp.tell() > os.stat(self.__logfile).st_size:
                                fp.seek(0)
                            self.__drain(fp)

                        elif (inotify_simple.flags.MOVE_SELF in mask_flags or
                              inotify_simple.flags.DELETE_SELF in mask_flags):
                            # Log rotation: drain remaining content, stop watching the old inode
                            self.__drain(fp)
                            fp.close()
                            fp = None
                            inotify.rm_watch(wd_file)
                            wd_file = None

                    elif event.wd == wd_dir and event.name == logfile_name:
                        # The log file was (re)created after rotation
                        if fp:
                            fp.close()
                        fp = open(self.__logfile, "r")
                        if wd_file is not None:
                            inotify.rm_watch(wd_file)
                        wd_file = inotify.add_watch(self.__logfile, file_flags)

        finally:
            if fp:
                fp.close()
            inotify.close()

        return 0


    def Shutdown(self):
        self.__shutdown = True

        # Shutdown rule specific reporters
        for r in self.__rules:
            if ("reporters" in r) and (r["reporters"] is not None):
                for rep in r["reporters"]:
                    rep._Shutdown()

        # Shutdown default reporters
        for rep in self.__reporters:
            rep._Shutdown()



class LogActio:
    def __init__(self, cfgfile, daemon=False, pidfile=None, logger=None, stdout="/dev/null"):
        self.__cfg = configparser.ConfigParser()
        res = self.__cfg.read(cfgfile)
        if len(res) == 0:
            raise Exception(f"Could not load the configuration file '{cfgfile}'")

        self.__watchthreads = []
        self.__ext_modules = {}
        self.__shutdown = False
        self.__daemon = daemon
        self.__pidfilename = pidfile

        self.__log = logger if logger is not None else self.__logfnc

        if daemon:
            self.__daemonise(stdout)

        if self.__pidfilename:
            with open(self.__pidfilename, 'w') as f:
                f.write(str(os.getpid()))

        self.__parse_cfg()


    def __logfnc(self, lvl, msg):
        print(f"[{lvl}] {msg}")


    def __daemonise(self, redir_stdout):
        try:
            pid = os.fork()
            if pid > 0:
                sys.exit(0)
        except OSError as e:
            sys.stderr.write(f"Failed to daemonise [step 1]: {e}\n")
            sys.exit(1)

        os.chdir("/")
        os.setsid()
        os.umask(0)

        try:
            pid = os.fork()
            if pid > 0:
                sys.exit(0)
        except OSError as e:
            sys.stderr.write(f"Failed to daemonise [step 2]: {e}\n")
            sys.exit(1)

        with open("/dev/null", "r") as stdin_f, \
             open(redir_stdout, "a+") as stdout_f, \
             open("/dev/null", "a+") as stderr_f:
            os.dup2(stdin_f.fileno(), sys.stdin.fileno())
            os.dup2(stdout_f.fileno(), sys.stdout.fileno())
            os.dup2(stderr_f.fileno(), sys.stderr.fileno())

        self.__log(0, f"Daemonised logactio, pid {os.getpid()}")


    def __parse_cfg(self):
        __logfileidx = []
        __reporters = {}

        # Retrieve all configured reporter modules, sections prefixed with 'Reporter:'
        repre = re.compile("Reporter:(.*)", re.IGNORECASE)
        for entry in [rr for rr in self.__cfg.sections() if repre.match(rr)]:
            repname = repre.match(entry).groups()[0]

            if repname in __reporters:
                raise Exception(f"The {repname} reporter has already been configured")

            # Get configuration for reporter
            repcfg = dict(self.__cfg.items(entry))

            if "module" in repcfg:
                # import this reporter module, but only if it is unknown
                extmodule = repcfg["module"]
                if extmodule not in self.__ext_modules:
                    self.__ext_modules[extmodule] = importlib.import_module(
                        f"LogActio.Reporters.{extmodule}"
                    )
            else:
                extmodule = None

            if repname == "Default":
                __reporters[repname] = DefaultReporter(repcfg, self.__log)
            elif extmodule is not None:
                __reporters[repname] = self.__ext_modules[extmodule].InitReporter(repcfg, self.__log)
            else:
                raise Exception(f"Dazed and confused - this shouldn't happen (repname: {repname})")

            del repcfg
            self.__log(2, f"Configured reporter {repname}: {__reporters[repname].GetName()}")

        if "Default" not in __reporters:
            __reporters["Default"] = DefaultReporter({}, self.__log)

        self.__log(1, f"Available reporter modules: {', '.join(__reporters.keys())}")

        # Retrieve all logfile configurations, sections prefixed with 'Logfile:'
        lfre = re.compile("Logfile:(.*)", re.IGNORECASE)
        for entry in [lf for lf in self.__cfg.sections() if lfre.match(lf)]:
            logname = lfre.match(entry).groups()[0]
            try:
                # Find the saved array index for this logfile
                logfile = self.__cfg.get(entry, "file")
                idx = __logfileidx.index(logname)
            except ValueError:
                # If index was not found, it's a new logfile
                __logfileidx.append(logname)
                idx = __logfileidx.index(logname)

                # Extract the default reporters to use for this log file
                try:
                    defreps = []
                    repnames = self.__cfg.get(entry, "reporters")
                    for repname in [n.strip() for n in repnames.split(",")]:
                        defreps.append(__reporters[repname])
                except configparser.NoOptionError:
                    defreps = [__reporters["Default"]]
                except KeyError:
                    raise Exception(f"No reporters are configured as '{repname}'")

                # Create a new thread which will watch this particular log file
                # and will use the configured reporter to handle matching events
                self.__watchthreads.append(WatcherThread(logfile, defreps))
                self.__log(3, f"Prepared [{logname}]: {logfile} => "
                              f"{', '.join([r.GetName() for r in defreps])}")

        self.__log(1, f"Configured log files: {', '.join(__logfileidx)}")

        # Retrieve all configured rules
        rulsre = re.compile("Rule:(.*)", re.IGNORECASE)
        for entry in [rl for rl in self.__cfg.sections() if rulsre.match(rl)]:
            rulename = rulsre.match(entry).groups()[0]
            try:
                # Look up the WatchThread index for the logfile this rule requires
                logf = self.__cfg.get(entry, "logfile")
                idx = __logfileidx.index(logf)
            except ValueError:
                raise Exception(f"Logfile '{self.__cfg.get(entry, 'logfile')}' is not configured")
            try:
                # Check if this rule has specific reporters configured
                rulereps = []
                repnames = self.__cfg.get(entry, "reporters")
                for repname in [n.strip() for n in repnames.split(",")]:
                    rulereps.append(__reporters[repname])
            except configparser.NoOptionError:
                rulereps = None
            except KeyError:
                raise Exception(f"No reporters are configured as '{repname}'")

            # Add the rule to the proper WatchThread
            _rrl = self.__cfg.get(entry, "reset-rule-rate-limits", fallback=None)
            resetrules = [r.strip() for r in _rrl.split(",")] if _rrl else None
            self.__watchthreads[idx].AddRule(
                rulename,
                self.__cfg.get(entry, "regex"),
                self.__cfg.get(entry, "threshold-type", fallback=None),
                self.__cfg.get(entry, "threshold"),
                self.__cfg.get(entry, "time-frame", fallback=None),
                self.__cfg.get(entry, "rate-limit", fallback=None),
                resetrules,
                rulereps)
            if rulereps:
                self.__log(3, f"Rule reporters prepared: [{rulename}] => "
                              f"{', '.join([r.GetName() for r in rulereps])}")
        del __logfileidx



    def __Shutdown(self):
        for wt in self.__watchthreads:
            wt.Shutdown()
            wt.join()
            self.__shutdown = True

        if self.__pidfilename:
            try:
                os.unlink(self.__pidfilename)
            except OSError:
                pass


    def __sighandler(self, signum, frame):
        if signum in (signal.SIGINT, signal.SIGTERM, signal.SIGUSR1):
            if self.__shutdown:
                self.__log(1, "Shutdown already in progress")
                return

            self.__log(0, "logactio shutdown initiated")
            self.__Shutdown()


    def Run(self):
        signal.signal(signal.SIGINT, self.__sighandler)
        signal.signal(signal.SIGTERM, self.__sighandler)
        signal.signal(signal.SIGUSR1, self.__sighandler)
        for wt in self.__watchthreads:
            wt.StartWatcher()

        while not self.__shutdown:
            time.sleep(5)

        self.__log(0, "logactio stopped")
