#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# PythonPlugin.py
# ---------
#
# Copyright © 2015 Pierre-Elliott Bécue <becue@crans.org>
"""Plugin servant à gérer des fichiers python, dont la sortie sera
la configuration d'un client."""

#: N'exporte que la classe Python
__all__ = [
    "Python",
]

import os
import re
import binascii

from Bcfg2.Server.Plugin import Plugin, Generator, PluginExecutionError, track_statistics
from Bcfg2.Server.Plugin.base import Debuggable

import PythonTools
import PythonDefaults
import PythonFile

class Python(Plugin, Generator, Debuggable):
    """Générateur offrant des fonctionnalités de templating pour les fichiers python"""

    name = 'Python'

    #: Les DirectoryBacked ont des fonctions de monitoring
    #: intégrées. Quand des changements arrivent sur les dossiers,
    #: c'est la merde, il est préférable de relancer Bcfg2, car
    #: FileMonitor ne sait pas démonitorer/remonitorer.
    #: En revanche, pour les fichiers, il appelle __child__ comme
    #: "générateur" pour les trucs à surveiller. Quand un fichier
    #: est créé/modifié, sa méthode HandleEvent est appelée.
    __child__ = PythonFile.PythonFile

    #: Ce module gère plein de choses.
    patterns = re.compile(r'.*')

    #: Ignore ces chemins spécifiques
    ignore = re.compile(r'.*\.(COMPILED|pyc)')

    __version__ = '2.0'

    __author__ = 'becue@crans.org'

    def __init__(self, core, datastore):
        """Pour initialiser le plugin"""

        #: Initialise un certain nombre de choses en background
        Plugin.__init__(self, core, datastore)
        Debuggable.__init__(self)

        #: self.entries contains information about the files monitored
        #: by this object. The keys of the dict are the relative
        #: paths to the files. The values are the objects (of type
        #: :attr:`__child__`) that handle their contents.
        self.entries = {}

        #: self.handles contains information about the directories
        #: monitored by this object. The keys of the dict are the
        #: values returned by the initial fam.AddMonitor() call (which
        #: appear to be integers). The values are the relative paths of
        #: the directories.
        self.handles = {}

        #: FileMonitor
        self.fam = self.core.fam

        #: Monitor everything in the plugin's directory
        if not os.path.exists(self.data):
            self.logger.warning("%s does not exist, creating" % (self.data,))
            os.makedirs(self.data)
        self.add_directory_monitor('')

        #: Dossier des includes
        self.include = os.path.abspath(os.path.join(self.data, PythonDefaults.INCLUDES))

        #: Quand on initialise un DirectoryBacked, on a déjà un monitoring de
        #: self.data, donc on a besoin que des includes
        self.add_directory_monitor(PythonDefaults.INCLUDES)

    @track_statistics()
    def HandlesEntry(self, entry, metadata):
        """Vérifie si l'entrée est gérée par le plugin"""
        relpath = entry.get('name')[1:]
        if relpath in self.entries:
            return True
        return False

    @track_statistics()
    def HandleEntry(self, entry, metadata):
        """Construit le fichier demandé"""
        # On récupère le code qui va bien.
        relpath = entry.get('name')[1:]
        python_file = self.entries[relpath]

        # Et le nom de fichier.
        fname = entry.get('realname', entry.get('name'))

        # Si on est en débug, on loggue ce qu'on fait.
        PythonTools.debug("Building config file: %s" % (fname,), PythonTools.LOGGER, 'blue')

        # On crée un environnement autonome pour exécuter le fichier.
        additionnal = {
            'metadata': metadata,
        }

        try:
            text, info = python_file.run(additionnal)
        except Exception as error:
            PythonTools.log_traceback(fname, 'exec', error, PythonTools.LOGGER)
            raise PluginExecutionError

        # On récupère les infos
        if info.get('encoding', '') == 'base64':
            text = binascii.b2a_base64(text)

        # lxml n'accepte que de l'ascii ou de l'unicode
        # donc faut décoder.
        try:
            entry.text = text.decode("UTF-8")
        except:
            # solution de fallback
            entry.text = text.decode("ISO-8859-15")

        # En cas de débug, on stocke les données
        PythonTools.debug(entry.text, PythonTools.LOGGER)

        # On récupère les permissions depuis le dico "info".
        # En théorie, les valeurs par défaut ne devraient pas être utilisées
        # Car elles sont déjà affectées dans Pygen
        entry.attrib['owner'] = info.get('owner', PythonDefaults.DEFAULT_USER)
        entry.attrib['group'] = info.get('group', PythonDefaults.DEFAULT_GROUP)
        entry.attrib['mode'] = oct(info.get('mode', PythonDefaults.DEFAULT_ACLS))

        if 'encoding' in info:
            entry.attrib['encoding'] = info['encoding']

    def add_directory_monitor(self, relative):
        """ Add a new directory to the FAM for monitoring.

        :param relative: Path name to monitor. This must be relative
                         to the plugin's directory. An empty string
                         value ("") will cause the plugin directory
                         itself to be monitored.
        :type relative: string
        :returns: None
        """

        #: On normalise pour éviter des problèmes quand le FileMonitor
        #: voit des changements par la suite.
        #: Les chemins sont absolus pour la même raison.
        dirpathname = os.path.normpath(os.path.join(self.data, relative))
        if relative not in self.handles.values():
            if not os.path.isdir(dirpathname):
                self.logger.error("%s is not a directory" % (dirpathname,))
                return
            #: reqid est un chemin absolu sans trailing slash
            reqid = self.fam.AddMonitor(dirpathname, self)
            self.handles[reqid] = relative

    def add_entry(self, relative, event):
        """ Add a new file to our tracked entries, and to our FAM for
        monitoring.

        :param relative: Path name to monitor. This must be relative
                         to the plugin's directory.
        :type relative: string:
        :param event: FAM event that caused this entry to be added.
        :type event: Bcfg2.Server.FileMonitor.Event
        :returns: None
        """
        #: Les entrées sont en relatif depuis le dossier de config
        self.entries[relative] = self.__child__(
            os.path.join(self.data, relative),
            self
        )
        self.entries[relative].HandleEvent(event)

    def HandleEvent(self, event):
        """ Handle FAM events.

        This method is invoked by the FAM when it detects a change to
        a filesystem object we have requsted to be monitored.

        This method manages the lifecycle of events related to the
        monitored objects, adding them to our list of entries and
        creating objects of type :attr:`__child__` that actually do
        the domain-specific processing. When appropriate, it
        propogates events those objects by invoking their HandleEvent
        method in turn.

        :param event: FAM event that caused this entry to be added.
        :type event: Bcfg2.Server.FileMonitor.Event
        :returns: None
        """
        action = event.code2str()

        # Exclude events for actions we don't care about
        if action == 'endExist':
            return

        if event.requestID not in self.handles:
            self.logger.warn(
                "Got %s event with unknown handle (%s) for %s" % (action, event.requestID, event.filename)
            )
            return

        # Clean up path names
        event.filename = os.path.normpath(event.filename)
        if event.filename.startswith(self.data) or os.path.normpath(event.requestID) == event.filename:
            # the first event we get is on the data directory itself
            event.filename = event.filename[len(os.path.normpath(event.requestID)) + 1:]

        if self.ignore and self.ignore.search(event.filename):
            self.logger.debug("Ignoring event %s" % (event.filename,))
            return

        # Calculate the absolute and relative paths this event refers to
        abspath = os.path.join(self.data, self.handles[event.requestID],
                               event.filename)
        relpath = os.path.join(self.handles[event.requestID],
                               event.filename).lstrip('/')

        if action == 'deleted':
            for key in list(self.entries.keys()):
                if key.startswith(relpath):
                    del self.entries[key]
            # We remove values from self.entries, but not
            # self.handles, because the FileMonitor doesn't stop
            # watching a directory just because it gets deleted. If it
            # is recreated, we will start getting notifications for it
            # again without having to add a new monitor.
        elif os.path.isdir(abspath):
            # Deal with events for directories
            if action in ['exists', 'created']:
                self.add_directory_monitor(relpath)
            elif action == 'changed':
                if relpath in self.entries:
                    # Ownerships, permissions or timestamps changed on
                    # the directory. None of these should affect the
                    # contents of the files, though it could change
                    # our ability to access them.
                    #
                    # It seems like the right thing to do is to cancel
                    # monitoring the directory and then begin
                    # monitoring it again. But the current FileMonitor
                    # class doesn't support canceling, so at least let
                    # the user know that a restart might be a good
                    # idea.
                    self.logger.warn(
                        "Directory properties for %s changed, please consider restarting the server" % (abspath)
                    )
                else:
                    # Got a "changed" event for a directory that we
                    # didn't know about. Go ahead and treat it like a
                    # "created" event, but log a warning, because this
                    # is unexpected.
                    self.logger.warn(
                        "Got %s event for unexpected dir %s" % (action, abspath)
                    )
                    self.add_directory_monitor(relpath)
            else:
                self.logger.warn(
                    "Got unknown dir event %s %s %s" % (event.requestID, event.code2str(), abspath)
                )
        elif self.patterns.search(event.filename):
            if action in ['exists', 'created']:
                self.add_entry(relpath, event)
            elif action == 'changed':
                if relpath in self.entries:
                    self.entries[relpath].HandleEvent(event)
                else:
                    # Got a "changed" event for a file that we didn't
                    # know about. Go ahead and treat it like a
                    # "created" event, but log a warning, because this
                    # is unexpected.
                    self.logger.warn(
                        "Got %s event for unexpected file %s" % (action, abspath)
                    )
                    self.add_entry(relpath, event)
            else:
                self.logger.warn(
                    "Got unknown file event %s %s %s" % (event.requestID, event.code2str(), abspath)
                )
        else:
            self.logger.warn(
                "Could not process filename %s; ignoring" % (event.filename)
            )
