#! /usr/bin/env python
# vim: fileencoding=utf-8
#
# Copyright (c) 2007-2008 Adeodato Simó (dato@net.com.org.es)
#
# This software may be used and distributed according to the terms
# of the MIT License, incorporated herein by reference.

import os
import re
import sys
import errno
import optparse
import pyinotify

from pyinotify import EventsCodes

ROOT_DIR = os.path.expanduser('~/.mail')
OUT_FILE = os.path.expanduser('~/.mutt/out/rc_monitored')

###

def main():
    options, arguments = parse_options()

    if options.check_missing:
        maildirs1, _ = find_maildirs(ROOT_DIR)
        maildirs1 = set(maildirs1)
        maildirs2 = set(muttfile_mailboxes(OUT_FILE, ROOT_DIR))
        missing =  maildirs1 - maildirs2
        if missing:
            for m in missing:
                print m
            return 1
        else:
            return 0

    watch_manager = pyinotify.WatchManager()
    notifier = pyinotify.Notifier(watch_manager)

    maildirs, _ = find_maildirs(ROOT_DIR)
    maildirs = [ WatchedMaildir(ROOT_DIR, path, watch_manager) for path in maildirs ]

    def sort_and_write():
        maildirs.sort()
        write_mutt_file(maildirs)

    sort_and_write()

    if not options.no_fork:
        daemonize()

    while True:
        notifier.check_events(timeout=None) # blocks
        notifier.read_events()
        notifier.process_events()
        sort_and_write()

###

def write_mutt_file(maildirs):
    f = file(OUT_FILE, 'w')
    f.write('mailboxes ' + ' '.join('=%s' % m.relpath for m in maildirs) + '\n')
    f.close()

###

class WatchedMaildir(pyinotify.ProcessEvent):
    """Watches a maildir for new or deleted messages.

    This class watches a maildir with inotify in order to keep track of the
    newest file under the new/ directory, which is used in __cmp__() as the
    global mtime of the maildir itself.

    This allows to sensibly sort maildirs based on the newest message in them.
    """

    def __init__(self, root, relpath, watch_manager):
        self.relpath = relpath
        self.abspath = os.path.join(root, relpath)
        self.new_dir = new_dir = os.path.join(self.abspath, 'new')

        if not os.path.isdir(new_dir):
            raise Exception, "%s does not exist" % new_dir

        self.messages = [ (os.stat(os.path.join(new_dir, name)).st_mtime, name)
                for name in os.listdir(new_dir) ]

        self.messages.sort(reverse=True)

        self.mtime = self.messages and self.messages[0][0] or 0

        watch_manager.add_watch(new_dir, EventsCodes.IN_CREATE |
                EventsCodes.IN_MOVED_TO | EventsCodes.IN_DELETE |
                EventsCodes.IN_MOVED_FROM, self)

    def _new_message(self, name):
        try:
            mtime = os.stat(os.path.join(self.new_dir, name)).st_mtime
        except OSError, e:
            if e.errno == errno.ENOENT: # race condition?
                return
            else:
                raise

        i = 0 # in case the list is empty
        for i, (i_mtime, i_name) in enumerate(self.messages):
            if mtime > i_mtime:
                break
        else:
            # i should not be incremented when messages is empty, so...
            i += i and 1 or 0

        self.messages.insert(i, (mtime, name))

        if i == 0:
            self.mtime = mtime

    def _del_message(self, name):
        i = -1
        for i, (i_mtime, i_name) in enumerate(self.messages):
            if name == i_name:
                self.messages.pop(i)
                break

        if i == 0:
            self.mtime = self.messages and self.messages[0][0] or 0

    def __cmp__(self, other):
        return -cmp(self.mtime, other.mtime) or cmp(self.relpath, other.relpath)

    def process_IN_CREATE(self, event):
        # print 'created:', event.name
        self._new_message(event.name)

    def process_IN_DELETE(self, event):
        # print 'deleted:', event.name
        self._del_message(event.name)

    process_IN_MOVED_TO = process_IN_CREATE
    process_IN_MOVED_FROM = process_IN_DELETE

###

def find_maildirs(root, nested=False):
    """Recursively finds maildirs in a given directory.

    Returns two lists, the first one is a list of found maildirs, and the
    second, a list of directories which *contained* maildirs. Returned paths
    are relative to root.

    Symlinks are not canonicalized, but if the target is under the given root,
    only the symlink is included in the returned list. (If two or more symlinks
    point to the same location, only one of them is returned, too.)

    Maildirs are assumed not to contain nested maildirs, unless :param nested:
    is True.
    """
    def strip_root(path):
        return re.sub('^%s/*' % root.rstrip('/'), '', path) or '.'

    maildirs = set()
    containers = set()
    seen = set()

    for prefix, subdirs, files in os.walk(root):
        for s in subdirs[:]: # make a copy because the list gets modified in the loop
            path = os.path.join(prefix, s)

            if not is_maildir(path):
                continue

            if not nested:
                subdirs.remove(s)

            if os.path.islink(path):
                realpath = strip_root(os.path.realpath(path))
                if realpath in seen:
                    continue
                else:
                    seen.add(realpath)
                    maildirs.discard(realpath)

            maildir = strip_root(path)
            container = strip_root(prefix)

            if maildir not in seen:
                maildirs.add(maildir)
                containers.add(container) # XXX this out of the if?

    return list(maildirs), list(containers)

def muttfile_mailboxes(muttfile, root=None):
    """Return a list of mailboxes defined in a muttrc file.
    
    The returned list will have relative and absolute paths, just as they're
    specified in the file. If :param root: is not None, that path will be
    stripped from absolute paths, making them relative if applicable.
    """
    mailboxes = []
    regex = re.compile(r'\bmailboxes\b([^;#\n]+)+')

    for line in file(muttfile):
        for group in regex.findall(line):
            mailboxes.extend(re.sub(r'^[=+]', '', x) for x in group.split())

    if root is not None:
        root = re.compile('^' + re.escape(root) + '/+')
        mailboxes = [ root.sub('', x) for x in mailboxes ]

    return mailboxes

###

def is_maildir(path):
    """Determine if a directory is a maildir."""

    for x in ('cur', 'new', 'tmp'):
        if not os.path.isdir(os.path.join(path, x)):
            return False
    return True

###

def daemonize():
    # http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/66012
    # http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/278731
    try:
        pid = os.fork()
        if pid > 0:
            os._exit(0)
    except OSError, e:
        print >>sys.stderr, "fork #1 failed: %d (%s)" % (e.errno, e.strerror)
        sys.exit(1)

    os.setsid()
    os.chdir('/')
    # os.umask(0022)

    try:
        pid = os.fork()
        if pid > 0:
            os._exit(0)
    except OSError, e:
        print >>sys.stderr, "fork #2 failed: %d (%s)" % (e.errno, e.strerror)
        sys.exit(1)

    devnull = os.open('/dev/null', os.O_RDWR)
    os.dup2(devnull, sys.stdin.fileno())
    os.dup2(devnull, sys.stdout.fileno())
    os.dup2(devnull, sys.stderr.fileno())
    os.close(devnull)

###

def parse_options():
    p = optparse.OptionParser()

    p.add_option('-f', '--foreground', action='store_true', dest='no_fork',
            help='do not detach from the controlling terminal')

    p.add_option('--check-missing', action='store_true', dest='check_missing',
            help='only list maildirs created since miw last started')

    options, args = p.parse_args()

    return options, args

###

if __name__ == '__main__':
    sys.exit(main())
