# -*- coding: utf-8 -*-
#
# Copyright (C) 2009 Edgewall Software
# All rights reserved.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution. The terms
# are also available at http://trac.edgewall.com/license.html.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
# history and logs, available at http://trac.edgewall.org/.

import re

from trac.config import *
from trac.core import Component, implements
from trac.ticket import Ticket
from trac.ticket.notification import TicketNotifyEmail
from trac.ticket.web_ui import TicketModule
from trac.util.text import exception_to_unicode
from trac.versioncontrol import IRepositoryChangeListener


class CommitTicketUpdate(Component):
    """Update tickets based on commit messages.
    
    This component hooks into changeset notifications and searches commit
    messages for text in the form of:
        command #1
        command #1, #2
        command #1 & #2 
        command #1 and #2
    
    Instead of the short-hand syntax "#1", "ticket:1" can be used as well,
    e.g.:
        command ticket:1
        command ticket:1, ticket:2
        command ticket:1 & ticket:2 
        command ticket:1 and ticket:2
    
    In addition, the ':' character can be omitted and issue or bug can be used
    instead of ticket.
    
    You can have more then one command in a message. The following commands
    are supported. There is more then one spelling for each command, to make
    this as user-friendly as possible.
    
        close, closed, closes, fix, fixed, fixes
             The specified issue numbers are closed with the contents of this
             commit message being added to it. 
        references, refs, addresses, re, see 
             The specified issue numbers are left in their current status, but 
             the contents of this commit message are added to their notes. 
    
    A fairly complicated example of what you can do is with a commit message
    of:
    
        Changed blah and foo to do this or that. Fixes #10 and #12,
        and refs #12.
    
    This will close #10 and #12, and add a note to #12.
    """
    
    implements(IRepositoryChangeListener)
    
    envelope = Option('ticket', 'commit_ref_envelope', '',
        """Require commands to be enclosed in an envelope.
        
        Must be empty or contain two characters. For example, if set to "[]",
        then commands must be in the form of [closes #4].
        """)

    commands_close = Option('ticket', 'commit_ref_commands.close',
        'close closed closes fix fixed fixes',
        """Commands that close tickets, as a space-separated list.""")
    
    commands_refs = Option('ticket', 'commit_ref_commands.refs',
        'addresses re references refs see',
        """Commands that add a reference, as a space-separated list.""")
    
    _ticket_prefix = '(?:#|(?:ticket|issue|bug)[: ]?)'
    _ticket_reference = _ticket_prefix + '[0-9]+'
    _ticket_command =  (r'(?P<action>[A-Za-z]*).?'
                       '(?P<ticket>%s(?:(?:[, &]*|[ ]?and[ ]?)%s)*)' %
                       (_ticket_reference, _ticket_reference))
    
    @property
    def _command_re(self):
        (begin, end) = (re.escape(self.envelope[0:1]),
                        re.escape(self.envelope[1:2]))
        return re.compile(begin + self._ticket_command + end)
    
    _ticket_re = re.compile(_ticket_prefix + '([0-9]+)')
    
    _last_cset_id = None
    
    # IRepositoryChangeListener methods
    
    def changeset_added(self, repos, changeset):
        # Avoid duplicate changes with multiple scoped repositories
        cset_id = (changeset.rev, changeset.message, changeset.author,
                   changeset.date)
        if cset_id == self._last_cset_id:
            return
        self._last_cset_id = cset_id
        
        revstring = str(changeset.rev)
        if repos.reponame:
            revstring += '/' + repos.reponame
        msg = '(In [%s]) %s' % (revstring, changeset.message)
        
        # Parse message
        cmd_groups = self._command_re.findall(msg)
        functions = self._get_functions()
        tickets = {}
        for cmd, tkts in cmd_groups:
            func = functions.get(cmd.lower())
            if func:
                for tkt_id in self._ticket_re.findall(tkts):
                    tickets.setdefault(tkt_id, []).append(func)
        
        # Update tickets
        db = self.env.get_db_cnx()
        for tkt_id, cmds in tickets.iteritems():
            try:
                self.log.debug("Updating ticket #%s", tkt_id)
                ticket = Ticket(self.env, int(tkt_id), db)
                for cmd in cmds:
                    cmd(ticket)
                
                # Determine sequence number
                cnum = 0
                tm = TicketModule(self.env)
                for change in tm.grouped_changelog_entries(ticket, db):
                    if change['permanent']:
                        cnum += 1
                
                ticket.save_changes(changeset.author, msg, changeset.date, db,
                                    cnum + 1)
                db.commit()
                
                try:
                    tn = TicketNotifyEmail(self.env)
                    tn.notify(ticket, newticket=False, modtime=changeset.date)
                except Exception, e:
                    self.log.error("Failure sending notification on change to "
                            "ticket #%s: %s", ticket.id,
                            exception_to_unicode(e))
            except Exception, e:
                self.log.error("Unexpected error while processing ticket "
                               "#%s: %s", ticket.id, exception_to_unicode(e))

    def _get_functions(self):
        """Create a mapping from commands to command functions."""
        functions = {}
        for each in dir(self):
            if not each.startswith('_cmd_'):
                continue
            func = getattr(self, each)
            for cmd in getattr(self, 'commands_' + each[5:], '').split():
                functions[cmd] = func
        return functions
    
    def _cmd_close(self, ticket):
        ticket['status'] = 'closed'
        ticket['resolution'] = 'fixed'

    def _cmd_refs(self, ticket):
        pass
    
    def changeset_modified(self, repos, changeset):
        pass

