#! /usr/bin/env python
## vim: fileencoding=utf-8

# Copyright (c) 2007 Adeodato Simó (dato@net.com.org.es)
# Licensed under the terms of the MIT license.

"""Send commit emails for Bazaar branches."""

import os
import sys
import socket
import optparse
import StringIO
import cStringIO

try:
    import pyinotify
    from pyinotify import EventsCodes
    _has_pyinotify = True
    _has_kernel_inotify = True
except ImportError:
    _has_pyinotify = False
    _has_kernel_inotify = None
except RuntimeError:
    _has_pyinotify = True
    _has_kernel_inotify = False

if not _has_pyinotify or not _has_kernel_inotify:
    class pyinotify:
        # need to have pyinotify.ProcessEvent available
        # for BranchEmailer to inherit from it
        class ProcessEvent:
            def __init__(self):
                pass

from bzrlib import revision as _mod_revision
from bzrlib import version_info as _bzrlib_version_info
from bzrlib.bzrdir import BzrDir
from bzrlib.diff import show_diff_trees
from bzrlib.errors import (NotBranchError, NoSuchRevision)
from bzrlib.log import log_formatter, show_log
from bzrlib.osutils import format_date

if _bzrlib_version_info >= (0, 19) and False: # we have local modifications
    from bzrlib.errors import SMTPError
    from bzrlib.email_message import EmailMessage
    from bzrlib.smtp_connection import SMTPConnection
else:
    from local_bzrlib.email_message import EmailMessage
    from local_bzrlib.smtp_connection import SMTPConnection, SMTPError

###

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

    if not options.email:
        print >>sys.stderr, 'E: no destination address given.'
        sys.exit(1)

    if options.recurse_dirs:
        arguments.extend(find_branches(options.recurse_dirs))
        if not arguments:
            print >>sys.stderr, 'E: no branches found.'
            sys.exit(1)

    if not arguments:
        print >>sys.stderr, 'E: no branches given.'
        sys.exit(1)

    # TODO: chdir to the common prefix of all branches? (nicer subjects)

    branches = []

    for branch_path in arguments:
        try:
            branch = BranchEmailer(branch_path)
            branch.update()
            branches.append(branch)
        except NotBranchError:
            print >>sys.stderr, 'W: could not open %s, skipping.' % branch_path

    if not options.daemon:
        return
    elif not _has_pyinotify:
        print >>sys.stderr, \
            'E: module pyinotify not found for daemon mode.'
        sys.exit(1)
    elif not _has_kernel_inotify:
        print >>sys.stderr, \
            'E: pyinotify could not find inotify support in the kernel.'
        sys.exit(1)

    if options.bg_fork:
        daemonize(options.logfile)

    try:
        # pyinotify >= 0.7
        watch_manager = pyinotify.WatchManager()
        notifier = pyinotify.Notifier(watch_manager)
    except AttributeError:
        # pyinotify 0.5
        watch_manager = notifier = pyinotify.SimpleINotify()
        notifier.check_events = notifier.event_check # \o/

    for branch in branches:
        watch_manager.add_watch(os.path.join(branch.path, '.bzr/branch'),
                EventsCodes.IN_CREATE | EventsCodes.IN_MODIFY |
                EventsCodes.IN_MOVED_TO, branch)

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

###

class BranchEmailer(pyinotify.ProcessEvent):

    _option_name = 'last_revision_mailed'

    def __init__(self, path):
        pyinotify.ProcessEvent.__init__(self)
        self.path = path
        self._branch = BzrDir.open_containing(path)[0].open_branch()
        self._config = self._branch.get_config()

    def update(self):
        smtp = SMTPConnection(self._config)
        smtp_from = None
        for revision in self._revisions_to_send():
            msg = self._compose_email(revision)
            try:
                smtp.send_email(msg)
            except SMTPError:
                # let's assume the server did not like the MAIL FROM
                try:
                    if smtp_from is None:
                        smtp_from = os.environ['LOGNAME'] + '@' + socket.getfqdn()
                    smtp.send_email(msg, smtp_from=smtp_from)
                except SMTPError, e:
                    print >>sys.stderr, \
                            'Could not send revision %s: %s' % (revision, e)
            # TODO: keep a list of failed revisions, and an option to "replay"
            self._config.set_user_option(self._option_name, revision)

    def _revisions_to_send(self):
        revision_history = self._branch.revision_history()
        last_mailed = self._config.get_user_option(self._option_name)

        if last_mailed is None:
            print >>sys.stderr, ('W: branch %s does not have %s set; setting it '
                'to the last available revision' % (self.path, self._option_name))
            # XXX: raises IndexError if watching an empty tree
            self._config.set_user_option(self._option_name, revision_history[-1])
            return []
        elif last_mailed not in revision_history:
            # The tip we last saw disappeared, either by uncommit or by having
            # been merged by a new mainline revision. So, we use as last_mailed
            # revision the first mainline ancestor of the disappeared one.
            revision = last_mailed.encode('utf-8')
            repo = self._branch.repository
            while True:
                try:
                    revobj = repo.get_revision(revision)
                except NoSuchRevision:
                    print >>sys.stderr, (
                        'E: %s does not exist in the repository for %s\n'
                        'E: setting %s to the last available revision' % (
                            last_mailed, self.path, self._option_name))
                    self._config.set_user_option(
                            self._option_name, revision_history[-1])
                    return []

                if revobj.parent_ids:
                    revision = revobj.parent_ids[0]
                    if revision in revision_history:
                        last_mailed = revision
                        break
                else:
                    return revision_history

        index = revision_history.index(last_mailed)
        return revision_history[index+1:]

    def _compose_email(self, revid):
        ### This code is based/stolen from that in bzr-email/emailer.py:
        ### Copyright (C) 2005, 2006, 2007 Canonical Ltd.
        ### Licensed under the terms of GPLv2 or later.
        rev1 = rev2 = self._branch.revision_id_to_revno(revid) or None
        revision = self._branch.repository.get_revision(revid)
        body = StringIO.StringIO()

        lf = log_formatter('long', to_file=body)
        show_log(self._branch, lf, start_revision=rev1, end_revision=rev2,
                verbose=True, direction='forward')

        msg = EmailMessage(revision.committer, options.email, u'%s r%d: %s' %
                (self.path, rev1, revision.get_summary()), body.getvalue())

        msg['Date'] = format_date(revision.timestamp, revision.timezone,
                date_fmt='%a, %d %b %Y %H:%M:%S')

        if options.line_limit != 0:
            diff = cStringIO.StringIO()
            diff_name = 'r%d.diff' % rev1

            revid_new = revision.revision_id

            self._branch.repository.lock_read()
            try:
                if revision.parent_ids:
                    revid_old = revision.parent_ids[0]
                    tree_new, tree_old = self._branch.repository.revision_trees(
                            (revid_new, revid_old))
                else:
                    # revision_trees() doesn't allow None or 'null:' to be passed
                    # as a revision. So we need to call revision_tree() twice.
                    revid_old = _mod_revision.NULL_REVISION
                    tree_new = self._branch.repository.revision_tree(revid_new)
                    tree_old = self._branch.repository.revision_tree(revid_old)
            finally:
                self._branch.repository.unlock()

            show_diff_trees(tree_old, tree_new, diff)
            numlines = diff.getvalue().count('\n') + 1
            if numlines <= options.line_limit or options.line_limit < 0:
                diff = diff.getvalue()
            else:
                diff = ("Diff too large for email (%d lines, the limit is %d).\n" %
                        (numlines, options.line_limit))

            msg.add_inline_attachment(diff, diff_name)

        return msg

    def process_IN_CREATE(self, event):
        if event.name in ['revision-history', 'last-revision']:
            self.update()

    process_IN_MODIFY = process_IN_CREATE
    process_IN_MOVED_TO = process_IN_CREATE

###

def find_branches(directories):
    branches = set()
    for toplevel in directories:
        for prefix, subdirs, files in os.walk(toplevel):
            if '.bzr' in subdirs and os.path.isdir(
                    os.path.join(prefix, '.bzr/branch')):
                branches.add(prefix)
                subdirs[:] = [] # no nested branches
    return branches

###

def daemonize(logfile):
    # 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.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_RDONLY)
    logfile = os.open(logfile, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0660)

    os.dup2(devnull, sys.stdin.fileno())
    os.dup2(logfile, sys.stdout.fileno())
    os.dup2(logfile, sys.stderr.fileno())

    os.close(devnull)
    os.close(logfile)

    # os.chdir('/')

###

def parse_options():
    p = optparse.OptionParser(usage='%prog -e ADDRESS [options] BRANCH [ BRANCH ... ]')

    p.add_option('-e', '--email', action='store',
            help='email address to send commit mails to')

    p.add_option('-r', '--recurse', action='append', dest='recurse_dirs',
            help=('watch all branches found under DIR; can be specified '
                  'multiple times'), metavar='DIR')

    p.add_option('-l', '--line-limit', action='store', type='int', metavar='N',
            help=('do not attach the diff if bigger than N lines '
                  '(default: 1000; -1: unlimited; 0: no diff)'))

    p.add_option('-d', '--daemon', action='store_true', dest='daemon',
            help='run as a daemon, watching branches with inotify')

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

    p.add_option('-o', '--logfile', action='store', metavar='FILE',
            help='print daemon errors to FILE (default: /dev/null)')

    p.set_defaults(bg_fork=True, logfile='/dev/null', line_limit=1000)

    options, args = p.parse_args()

    return options, args

###

if __name__ == '__main__':
    try:
        main()
    except StandardError:
        if options.daemon and options.bg_fork:
            # log the traceback into an specific file if in daemon bg mode
            exception_log = os.path.expanduser('~/.bzr_hookless_email.traceback')
            exception_log = os.open(exception_log, os.O_WRONLY | os.O_CREAT, 0660)
            os.dup2(exception_log, sys.stderr.fileno())
            os.close(exception_log)
        raise
