#!/usr/bin/env python
# -*- coding: utf-8 -*-
# BAREOS - Backup Archiving REcovery Open Sourced
#
# Copyright (C) 2014-2026 Bareos GmbH & Co. KG
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of version three of the GNU Affero General Public
# License as published by the Free Software Foundation, which is
# listed in the file LICENSE.
#
# This program 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 GNU
# Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# Author: Maik Aussendorf
#
# Baseclass for Bareos python plugins
# Functions taken and adapted from bareos-fd.py

import bareosfd
from bareosfd import *
import os
import stat
import sys
import time
from base64 import b85decode


def parse_plugindef_string(plugindef, multi_value_options):
    options = {}
    parts = plugindef.split(":")
    while parts:
        raw_key, _, raw_val = parts.pop(0).partition("=")
        key = raw_key.strip()
        val = raw_val.strip()
        if not key:
            continue
        if not val:
            bareosfd.JobMessage(
                M_WARNING, "ignoring option '%s' with no value!\n" % (key)
            )
            continue
        # See if the last character is a escape of the value string
        while val[-1:] == "\\":
            val = val[:-1] + ":" + parts.pop(0)
        if key in multi_value_options:
            options.setdefault(key, []).append(val)
        elif key not in options:
            options[key] = val
    return options


def parse_options_file(path, multi_value_options):
    bareosfd.DebugMessage(100, 'reading options from file "%s"\n' % (path))
    options = {}
    with open(path, "r") as fp:
        lines = fp.readlines()
        while lines:
            line = lines.pop(0)
            raw_key, _, raw_val = line.partition("=")
            key = raw_key.strip()
            val = raw_val.strip()
            if key == "" and val == "":  # empty line
                continue
            elif key.startswith(("#", ";", "[")):
                continue
            elif not key or not val:
                bareosfd.JobMessage(
                    M_FATAL,
                    "error parsing '%s' (found no key or value)!\n" % (line.strip()),
                )
                return {}
            # check if last character escapes the newline
            while val[-1:] == "\\":
                val = val[:-1] + lines.pop(0).strip()
            if key in multi_value_options:
                options.setdefault(key, []).append(val)
            else:
                if key in options:
                    bareosfd.JobMessage(
                        M_WARNING,
                        "duplicate option '%s' overriding previous value!\n" % (key),
                    )
                options[key] = val
    return options


def get_config_path():
    """
    we can only get the effective configuration file from core, so this function
    tries to convert it to a path that our plugin configuration can be relative to
    """
    used_config = bareosfd.GetValue(bareosfd.bVarUsedConfig)
    if used_config.endswith("/*/*.conf"):
        path = used_config[:-8]
    elif os.path.isfile(used_config):
        path = os.path.dirname(used_config)
    else:
        return "/"
    if os.path.isdir(path):
        return path
    else:
        return "/"


def transform_value(value, transform):
    if transform == "enc":
        return b85decode(value).decode("utf-8")
    else:
        raise NameError("unknown transformation")
    return value


class BareosFdPluginBaseclass(object):
    """Bareos python plugin base class"""

    def __init__(self, plugindef, mandatory_options=None):
        bareosfd.DebugMessage(
            100,
            "Constructor called in module %s with plugindef=%s\n"
            % (__name__, plugindef),
        )
        bareosfd.DebugMessage(
            100,
            "{}::__init__:  sys.path={}\n".format(__name__, str(sys.path)),
        )
        events = []
        events.append(bEventJobEnd)
        events.append(bEventEndBackupJob)
        events.append(bEventEndFileSet)
        events.append(bEventHandleBackupFile)
        events.append(bEventStartBackupJob)
        events.append(bEventStartRestoreJob)
        bareosfd.RegisterEvents(events)
        # get some static Bareos values
        self.fdname = bareosfd.GetValue(bVarFDName)
        self.jobId = bareosfd.GetValue(bVarJobId)
        self.client = bareosfd.GetValue(bVarClient)
        self.since = bareosfd.GetValue(bVarSinceTime)
        self.level = bareosfd.GetValue(bVarLevel)
        self.jobName = bareosfd.GetValue(bVarJobName)
        # jobName is of format myName.2020-05-12_11.35.27_05
        # short Name is everything left of the third point seen from the right
        self.shortName = self.jobName.rsplit(".", 3)[0]
        self.workingdir = bareosfd.GetValue(bVarWorkingDir)
        self.configdir = get_config_path()
        self.startTime = int(time.time())
        self.FNAME = "undef"
        self.filetype = "undef"
        self.file = None
        bareosfd.DebugMessage(100, "FDName = %s\n" % (self.fdname))
        bareosfd.DebugMessage(100, "WorkingDir: %s\n" % (self.workingdir))
        bareosfd.DebugMessage(100, "JobId: %s\n" % (self.jobId))
        self.mandatory_options = mandatory_options

    def __str__(self):
        return (
            "<%s:fdname=%s jobId=%s client=%s since=%d level=%c jobName=%s workingDir=%s>"
            % (
                self.__class__,
                self.fdname,
                self.jobId,
                self.client,
                self.since,
                self.level,
                self.jobName,
                self.workingdir,
            )
        )

    def _load_options_from_file(self, option_dict, key, multi_value_options):
        """
        consume the given key from the option_dict if it exists and return
        the options that are configured in the file the option pointed to
        """
        if key in option_dict:
            try:
                self.configdir
            except AttributeError:
                self.configdir = get_config_path()
                JobMessage(
                    M_WARNING, "Plugin did not call BareosFdPluginBaseclass.__init__()"
                )
            path = os.path.join(self.configdir, option_dict[key])
            try:
                options = parse_options_file(path, multi_value_options)
            except OSError as e:
                JobMessage(
                    M_FATAL,
                    "While loading options file '%s': %s" % (option_dict[key], e),
                )
                raise
            del option_dict[key]
            return options
        else:
            return {}

    def _add_options(self, options):
        for key, value in options.items():
            key_name, _, transform = key.partition("#")
            if transform:
                key = key_name
                try:
                    value = transform_value(value, transform)
                except Exception as e:
                    raise ValueError(
                        "Transformation %s of value '%s' for key %s failed: %s"
                        % (transform, value, key_name, e)
                    )

            if key not in self.options:
                bareosfd.DebugMessage(100, 'key:value = "%s:%s"\n' % (key, value))
                self.options[key] = value

    def parse_plugin_definition(self, plugindef, multi_value_options=None):
        bareosfd.DebugMessage(100, 'plugin def parser called with "%s"\n' % (plugindef))
        # Parse plugin options into a dict
        if not hasattr(self, "options"):
            self.options = dict()

        if multi_value_options is None:
            multi_value_options = []

        # builtin options that we handle differently cannot be multi value
        builtins = ["defaults_file", "overrides_file"]
        multi_value_options = [x for x in multi_value_options if x not in builtins]

        plugindef_options = parse_plugindef_string(plugindef, multi_value_options)
        try:
            default_options = self._load_options_from_file(
                plugindef_options, "defaults_file", multi_value_options
            )
            override_options = self._load_options_from_file(
                plugindef_options, "overrides_file", multi_value_options
            )
        except OSError:
            return False

        effective_options = {}
        effective_options.update(default_options)
        effective_options.update(plugindef_options)
        effective_options.update(override_options)

        try:
            self._add_options(effective_options)
        except ValueError as e:
            JobMessage(M_FATAL, str(e))
            return False

        return self.check_options(self.mandatory_options)

    def check_options(self, mandatory_options=None):
        """
        Check Plugin options
        Here we just verify that eventual mandatory options are set.
        If you have more to veriy, just overwrite this method in your class
        """
        if mandatory_options is None:
            return bRC_OK
        for option in mandatory_options:
            if option not in self.options:
                bareosfd.DebugMessage(
                    100, "Mandatory option '%s' not defined.\n" % option
                )
                bareosfd.JobMessage(
                    M_FATAL,
                    "Mandatory option '%s' not defined.\n" % (option),
                )
                return bRC_Error
            bareosfd.DebugMessage(
                100, "Using Option %s=%s\n" % (option, self.options[option])
            )
        return bRC_OK

    def plugin_io_open(self, IOP):
        self.FNAME = IOP.fname
        bareosfd.DebugMessage(250, "io_open: self.FNAME is set to %s\n" % (self.FNAME))
        if os.path.isdir(self.FNAME):
            bareosfd.DebugMessage(100, "%s is a directory\n" % (self.FNAME))
            self.fileType = "FT_DIR"
            bareosfd.DebugMessage(
                100,
                "Did not open file %s of type %s\n" % (self.FNAME, self.fileType),
            )
            return bRC_OK
        elif os.path.islink(self.FNAME):
            self.fileType = "FT_LNK"
            bareosfd.DebugMessage(
                100,
                "Did not open file %s of type %s\n" % (self.FNAME, self.fileType),
            )
            return bRC_OK
        elif os.path.exists(self.FNAME) and stat.S_ISFIFO(os.stat(self.FNAME).st_mode):
            self.fileType = "FT_FIFO"
            bareosfd.DebugMessage(
                100,
                "Did not open file %s of type %s\n" % (self.FNAME, self.fileType),
            )
            return bRC_OK
        else:
            self.fileType = "FT_REG"
            bareosfd.DebugMessage(
                150,
                "file %s has type %s - trying to open it\n"
                % (self.FNAME, self.fileType),
            )
        try:
            if IOP.flags & (os.O_CREAT | os.O_WRONLY):
                bareosfd.DebugMessage(
                    100,
                    "Open file %s for writing with %s\n" % (self.FNAME, IOP),
                )
                dirname = os.path.dirname(self.FNAME)
                if not os.path.exists(dirname):
                    bareosfd.DebugMessage(
                        100,
                        "Directory %s does not exist, creating it now\n" % (dirname),
                    )
                    os.makedirs(dirname)
                self.file = open(self.FNAME, "wb")
            else:
                bareosfd.DebugMessage(
                    100,
                    "Open file %s for reading with %s\n" % (self.FNAME, IOP),
                )
                self.file = open(self.FNAME, "rb")
        except:
            IOP.status = -1
            return bRC_Error
        return bRC_OK

    def plugin_io_close(self, IOP):
        bareosfd.DebugMessage(100, "Closing file " + "\n")
        if self.fileType == "FT_REG":
            self.file.close()
        return bRC_OK

    def plugin_io_seek(self, IOP):
        return bRC_OK

    def plugin_io_read(self, IOP):
        if self.fileType == "FT_REG":
            bareosfd.DebugMessage(
                200, "Reading %d from file %s\n" % (IOP.count, self.FNAME)
            )
            IOP.buf = bytearray(IOP.count)
            try:
                IOP.status = self.file.readinto(IOP.buf)
                IOP.io_errno = 0
            except Exception as e:
                bareosfd.JobMessage(
                    M_ERROR,
                    'Could net read %d bytes from file %s. "%s"'
                    % (IOP.count, file_to_backup, e.message),
                )
                IOP.io_errno = e.errno
                return bRC_Error
        else:
            bareosfd.DebugMessage(
                100,
                "Did not read from file %s of type %s\n" % (self.FNAME, self.fileType),
            )
            IOP.buf = bytearray()
            IOP.status = 0
            IOP.io_errno = 0
        return bRC_OK

    def plugin_io_write(self, IOP):
        bareosfd.DebugMessage(200, "Writing buffer to file %s\n" % (self.FNAME))
        try:
            self.file.write(IOP.buf)
        except Exception as e:
            bareosfd.JobMessage(
                M_ERROR,
                'Could net write to file %s. "%s"' % (file_to_backup, e.message),
            )
            IOP.io_errno = e.errno
            IOP.status = 0
            return bRC_Error
        IOP.status = IOP.count
        IOP.io_errno = 0
        return bRC_OK

    def plugin_io(self, IOP):
        """
        Basic IO operations. Some tweaks here: IOP.fname is only set on file-open
        We need to capture it on open and keep it for the remaining procedures
        Now (since 2020) separated into sub-methods to ease overloading in derived classes
        """
        bareosfd.DebugMessage(
            250,
            "plugin_io called with function %s filename %s\n" % (IOP.func, IOP.fname),
        )
        bareosfd.DebugMessage(250, "self.FNAME is set to %s\n" % (self.FNAME))
        if IOP.func == IO_OPEN:
            return self.plugin_io_open(IOP)
        elif IOP.func == IO_CLOSE:
            return self.plugin_io_close(IOP)
        elif IOP.func == IO_SEEK:
            return self.plugin_io_seek(IOP)
        elif IOP.func == IO_READ:
            return self.plugin_io_read(IOP)
        elif IOP.func == IO_WRITE:
            return self.plugin_io_write(IOP)

    def handle_plugin_event(self, event):
        if event == bEventJobEnd:
            bareosfd.DebugMessage(100, "handle_plugin_event called with bEventJobEnd\n")
            return self.end_job()

        elif event == bEventEndBackupJob:
            bareosfd.DebugMessage(
                100, "handle_plugin_event called with bEventEndBackupJob\n"
            )
            return self.end_backup_job()

        elif event == bEventEndFileSet:
            bareosfd.DebugMessage(
                100, "handle_plugin_event called with bEventEndFileSet\n"
            )
            return self.end_fileset()

        elif event == bEventStartBackupJob:
            bareosfd.DebugMessage(
                100, "handle_plugin_event() called with bEventStartBackupJob\n"
            )
            return self.start_backup_job()

        elif event == bEventStartRestoreJob:
            bareosfd.DebugMessage(
                100,
                "handle_plugin_event() called with bEventStartRestoreJob\n",
            )
            return self.start_restore_job()

        else:
            bareosfd.DebugMessage(
                100, "handle_plugin_event called with event %s\n" % (event)
            )
        return bRC_OK

    def start_backup_job(self):
        """
        Start of Backup Job. Called just before backup job really start.
        Overload this to arrange whatever you have to do at this time.
        """
        return bRC_OK

    def end_job(self):
        """
        Called if job ends regularyly (not for cancelled jobs)
        Overload this to arrange whatever you have to do at this time.
        """
        return bRC_OK

    def end_backup_job(self):
        """
        Called if backup job ends, before ClientAfterJob
        Overload this to arrange whatever you have to do at this time.
        """
        return bRC_OK

    def start_backup_file(self, savepkt):
        """
        Base method, we do not add anything, overload this method with your
        implementation to add files to backup fileset
        """
        bareosfd.DebugMessage(100, "start_backup called\n")
        return bRC_Stop

    def end_backup_file(self):
        bareosfd.DebugMessage(100, "end_backup_file() entry point in Python called\n")
        return bRC_OK

    def end_fileset(self):
        bareosfd.DebugMessage(100, "end_fileset() entry point in Python called\n")
        return bRC_OK

    def start_restore_job(self):
        """
        Start of Restore Job. Called , if you have Restore objects.
        Overload this to handle restore objects, if applicable
        """
        return bRC_OK

    def start_restore_file(self, cmd):
        bareosfd.DebugMessage(
            100,
            "start_restore_file() entry point in Python called with %s\n" % (cmd),
        )
        return bRC_OK

    def end_restore_file(self):
        bareosfd.DebugMessage(100, "end_restore_file() entry point in Python called\n")
        return bRC_OK

    def restore_object_data(self, ROP):
        bareosfd.DebugMessage(100, "restore_object_data called with " + str(ROP) + "\n")
        return bRC_OK

    def create_file(self, restorepkt):
        """
        Creates the file to be restored and directory structure, if needed.
        Adapt this in your derived class, if you need modifications for
        virtual files or similar
        """
        bareosfd.DebugMessage(
            100,
            "create_file() entry point in Python called with %s\n" % (restorepkt),
        )
        # We leave file creation up to the core for the default case
        restorepkt.create_status = CF_CORE
        return bRC_OK

    def set_file_attributes(self, restorepkt):
        bareosfd.DebugMessage(
            100,
            "set_file_attributes() entry point in Python called with %s\n"
            % (str(restorepkt)),
        )
        return bRC_OK

    def check_file(self, fname):
        bareosfd.DebugMessage(
            100,
            "check_file() entry point in Python called with %s\n" % (fname),
        )
        return bRC_OK

    def handle_backup_file(self, savepkt):
        bareosfd.DebugMessage(
            100,
            "handle_backup_file() entry point in Python called with %s\n" % (savepkt),
        )
        return bRC_OK

    def get_acl(self, acl):
        bareosfd.DebugMessage(
            100, "my get_acl() entry point in Python called with %s\n" % (acl)
        )
        return bareosfd.bRC_OK

    def set_acl(self, acl):
        bareosfd.DebugMessage(
            100, "my set_acl() entry point in Python called with %s\n" % (acl)
        )
        bareosfd.JobMessage(
            bareosfd.M_INFO,
            "my set_acl() entry point in Python called with %s\n" % (acl),
        )
        return bareosfd.bRC_OK

    def get_xattr(self, xattr):
        bareosfd.DebugMessage(
            100, "my get_xattr() entry point in Python called with %s\n" % (xattr)
        )

        # return values:
        #   bareosfd.bRC_More to be called again to add more xattrs,
        #   bareosfd.bRC_OK to finish adding xattrs
        return bareosfd.bRC_OK

    def set_xattr(self, xattr):
        bareosfd.DebugMessage(
            100, "my set_xattr() entry point in Python called with %s\n" % (xattr)
        )
        bareosfd.JobMessage(
            bareosfd.M_INFO,
            "my set_xattr() entry point in Python called with %s\n" % (xattr),
        )
        return bareosfd.bRC_OK


# vim: ts=4 tabstop=4 expandtab shiftwidth=4 softtabstop=4
