"""File monitor backend with `inotify <http://inotify.aiken.cz/>`_
support. """

import os
import logging
import pyinotify
from Bcfg2.Compat import reduce  # pylint: disable=W0622
from Bcfg2.Server.FileMonitor import Event
from Bcfg2.Server.FileMonitor.Pseudo import Pseudo

LOGGER = logging.getLogger(__name__)


class Inotify(Pseudo, pyinotify.ProcessEvent):
    """ File monitor backend with `inotify
    <http://inotify.aiken.cz/>`_ support. """

    #: Inotify is the best FAM backend, so it gets a very high
    #: priority
    __priority__ = 99

    # pylint: disable=E1101
    #: Map pyinotify event constants to FAM :ref:`event codes
    #: <development-fam-event-codes>`.  The mapping is not
    #: terrifically exact.
    action_map = {pyinotify.IN_CREATE: 'created',
                  pyinotify.IN_DELETE: 'deleted',
                  pyinotify.IN_MODIFY: 'changed',
                  pyinotify.IN_MOVED_FROM: 'deleted',
                  pyinotify.IN_MOVED_TO: 'created'}
    # pylint: enable=E1101

    #: The pyinotify event mask.  We only ask for events that are
    #: listed in :attr:`action_map`
    mask = reduce(lambda x, y: x | y, action_map.keys())

    def __init__(self, ignore=None, debug=False):
        Pseudo.__init__(self, ignore=ignore, debug=debug)
        pyinotify.ProcessEvent.__init__(self)

        #: inotify can't set useful monitors directly on files, only
        #: on directories, so when a monitor is added on a file we add
        #: its parent directory to ``event_filter`` and then only
        #: produce events on a file in that directory if the file is
        #: listed in ``event_filter``.  Keys are directories -- the
        #: parent directories of individual files that are monitored
        #: -- and values are lists of full paths to files in each
        #: directory that events *should* be produced for.  An event
        #: on a file whose parent directory is in ``event_filter`` but
        #: which is not itself listed will be silently suppressed.
        self.event_filter = dict()

        #: inotify doesn't like monitoring a path twice, so we keep a
        #: dict of :class:`pyinotify.Watch` objects, keyed by monitor
        #: path, to avoid trying to create duplicate monitors.
        #: (Duplicates can happen if an object accidentally requests
        #: duplicate monitors, or if two files in a single directory
        #: are both individually monitored, since inotify can't set
        #: monitors on the files but only on the parent directories.)
        self.watches_by_path = dict()

        #: The :class:`pyinotify.ThreadedNotifier` object.  This is
        #: created in :func:`start` after the server is done
        #: daemonizing.
        self.notifier = None

        #: The :class:`pyinotify.WatchManager` object. This is created
        #: in :func:`start` after the server is done daemonizing.
        self.watchmgr = None

        #: The queue used to record monitors that are added before
        #: :func:`start` has been called and :attr:`notifier` and
        #: :attr:`watchmgr` are created.
        self.add_q = []

    def start(self):
        """ The inotify notifier and manager objects in
        :attr:`notifier` and :attr:`watchmgr` must be created by the
        daemonized process, so they are created in ``start()``. Before
        those objects are created, monitors are added to
        :attr:`add_q`, and are created once the
        :class:`pyinotify.ThreadedNotifier` and
        :class:`pyinotify.WatchManager` objects are created."""
        Pseudo.start(self)
        self.watchmgr = pyinotify.WatchManager()
        self.notifier = pyinotify.ThreadedNotifier(self.watchmgr, self)
        self.notifier.start()
        for monitor in self.add_q:
            self.AddMonitor(*monitor)
        self.add_q = []

    def fileno(self):
        if self.started:
            return self.watchmgr.get_fd()
        else:
            return None
    fileno.__doc__ = Pseudo.fileno.__doc__

    def process_default(self, ievent):
        """ Process all inotify events received.  This process a
        :class:`pyinotify._Event` object, creates a
        :class:`Bcfg2.Server.FileMonitor.Event` object from it, and
        adds that event to :attr:`events`.

        :param ievent: Event to be processed
        :type ievent: pyinotify._Event
        """
        action = ievent.maskname
        for amask, aname in self.action_map.items():
            if ievent.mask & amask:
                action = aname
                break
        else:
            # event action is not in the mask, and thus is not
            # something we care about
            self.debug_log("Ignoring event %s for %s" % (action,
                                                         ievent.pathname))
            return

        try:
            watch = self.watchmgr.watches[ievent.wd]
        except KeyError:
            LOGGER.error("Error handling event %s for %s: Watch %s not found" %
                         (action, ievent.pathname, ievent.wd))
            return
        # FAM-style file monitors return the full path to the parent
        # directory that is being watched, relative paths to anything
        # contained within the directory. since we can't use inotify
        # to watch files directly, we have to sort of guess at whether
        # this watch was actually added on a file (and thus is in
        # self.event_filter because we're filtering out other events
        # on the directory) or was added directly on a directory.
        if (watch.path == ievent.pathname or ievent.wd in self.event_filter):
            path = ievent.pathname
        else:
            # relative path
            path = os.path.basename(ievent.pathname)
        # figure out the handleID.  start with the path of the event;
        # that should catch events on files that are watched directly.
        # (we have to watch the directory that a file is in, so this
        # lets us handle events on different files in the same
        # directory -- and thus under the same watch -- with different
        # objects.)  If the path to the event doesn't have a handler,
        # use the path of the watch itself.
        handleID = ievent.pathname
        if handleID not in self.handles:
            handleID = watch.path
        evt = Event(handleID, path, action)

        if (ievent.wd not in self.event_filter or
            ievent.pathname in self.event_filter[ievent.wd]):
            self.events.append(evt)

    def AddMonitor(self, path, obj, handleID=None):
        # strip trailing slashes
        path = path.rstrip("/")

        if not self.started:
            self.add_q.append((path, obj))
            return path

        if not os.path.isdir(path):
            # inotify is a little wonky about watching files.  for
            # instance, if you watch /tmp/foo, and then do 'mv
            # /tmp/bar /tmp/foo', it processes that as a deletion of
            # /tmp/foo (which it technically _is_, but that's rather
            # useless -- we care that /tmp/foo changed, not that it
            # was first deleted and then created).  In order to
            # effectively watch a file, we have to watch the directory
            # it's in, and filter out events for other files in the
            # same directory that are not similarly watched.
            # watch_transient_file requires a Processor _class_, not
            # an object, so we can't have this object handle events,
            # which is Wrong, so we can't use that function.
            watch_path = os.path.dirname(path)
            is_dir = False
        else:
            watch_path = path
            is_dir = True

        # see if this path is already being watched
        try:
            watchdir = self.watches_by_path[watch_path]
        except KeyError:
            watchdir = self.watchmgr.add_watch(watch_path, self.mask,
                                               quiet=False)[watch_path]
            self.watches_by_path[watch_path] = watchdir

        produce_exists = True
        if not is_dir:
            if watchdir not in self.event_filter:
                self.event_filter[watchdir] = [path]
            elif path not in self.event_filter[watchdir]:
                self.event_filter[watchdir].append(path)
            else:
                # we've been asked to watch a file that we're already
                # watching, so we don't need to produce 'exists'
                # events
                produce_exists = False

        # inotify doesn't produce initial 'exists' events, so we
        # inherit from Pseudo to produce those
        if produce_exists:
            return Pseudo.AddMonitor(self, path, obj, handleID=path)
        else:
            self.handles[path] = obj
            return path
    AddMonitor.__doc__ = Pseudo.AddMonitor.__doc__

    def shutdown(self):
        if self.notifier:
            self.notifier.stop()
    shutdown.__doc__ = Pseudo.shutdown.__doc__
