#
# act.py - DITrack 'act' command
#
# Copyright (c) 2006-2008 The DITrack Project, www.ditrack.org.
#
# $Id: act.py 2516 2008-05-26 14:25:52Z gli $
# $HeadURL: https://svn.xiolabs.com/ditrack/src/tags/0.8/DITrack/Command/act.py $
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
#  * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#  * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#

import copy
import email
import email.Message
import os
import sys

# DITrack modules
import DITrack.Command.generic
import DITrack.Edit
import DITrack.UI
import DITrack.Util.common

class _InconsistencyError(Exception):
    """
    The action would create a database inconsistency.
    """

    def __init__(self, issue_id, message):
        self.issue_id = issue_id
        self.message = message

class _Driver:
    """
    Class representing a driver: en entity that generates actions to be 
    performed on the issue(s).
    """

    def __init__(self, globals, dbcfg, issues, future_versions, vsets):
        """
        Initializes the driver environment. Parameters are:

        DBCFG
            A database configuration object.

        FUTURE_VERSIONS
            A list of version strings that can be used as future versions. May
            be empty if the issues being dealt with don't have common future
            versions.

        GLOBALS
            Globals object.

        ISSUES
            A dictionary of issue objects. Keys are issue ids (strings).

        VSETS
            A list of version sets of the issues we deal with. No duplicates
            allowed.
        """

        self._dbcfg = dbcfg
        self._future_versions = future_versions
        self._globals = globals
        self._issues = issues
        self._vsets = vsets

        #
        # Helper data
        #

        self._single_issue = (len(self._issues) == 1)

        self._issue_numbers = self._issues.keys()
        self._issue_numbers.sort(lambda x,y: cmp(int(x), int(y)))

    def _change_issues_due_version(self, version):
        """
        Changes due version of all issues to VERSION.
        """
        for i in self._issues.values():
            i.change_due_version(version)

    def _close_issues(self, resolution):
        """
        Close all issues with specified RESOLUTION.
        """
        assert resolution

        for id in self._issues:
            try:
                self._issues[id].close(resolution)
            except DITrack.DB.Exceptions.InconsistentActionError, msg:
                raise _InconsistencyError(id, msg)

    def _reassign_issues(self, owner):
        """
        Reassign all issues to OWNER.
        """

        for i in self._issues.values():
            i.reassign(owner)

    def _reopen_issues(self):
        """
        Reopen all issues.
        """

        for id in self._issues:
            try:
                self._issues[id].reopen()
            except DITrack.DB.Exceptions.InconsistentActionError, msg:
                raise _InconsistencyError(id, msg)

    def _change_issues_header(self, header):
        """
        Update (or add) a single header of/to all issues. The header name and
        its new value are passed in HEADER, separated by '='.

        Raises ValueError if HEADER couldn't be parsed out.
        """
        k, v = header.split("=", 1)
        for issue in self._issues.itervalues():
            # XXX: should probably use internal method like replace_header().
            issue.info[k] = v

    def run(self):
        """
        Runs the driver. Returns a sorted list of issue numbers (XXX) to try
        saving. Empty list returned means "don't save the changes".
        """
        raise NotImplementedError

class _CmdlineDriver(_Driver):
    """
    Action driver for noninteractive (command line) sessions.
    """

    def __init__(self, actionlist, comment_text, **kv):
        """
        ACTIONLIST is a parameter to '-a' option, as described in the usage 
        note. COMMENT_TEXT is the comment text to add (may be "").

        The rest parameters are the same as the base class constructor method
        accepts.
        """
        _Driver.__init__(self, **kv)

        if actionlist:
            self._actions = actionlist.strip().split(",")
        else:
            self._actions = []

        self.comment_text = comment_text

    def _invalid_action(self, action, issue, msg):
        """
        Prints out diagnostic message MSG about ACTION on ISSUE (string) and
        returns an empty list.
        """

        DITrack.Util.common.err(
            "Can't do '%s' on i#%s: %s" % (action, issue, msg)
        )
        return []

    def run(self):

        for a in self._actions:
            a = a.split(":")
            if len(a) == 1:
                action, arg = a[0], None
            elif len(a) == 2:
                action, arg = a
            else:
                DITrack.Util.common.err("Invalid action list syntax")

            if action == "change-due":

                if (not arg) or (arg not in self._future_versions):
                    DITrack.Util.common.err(
                        "'change-due' requires a valid future version number "
                        "as the argument"
                    )

                self._change_issues_due_version(arg)

            elif action == "close":

                valid_resolutions = ("dropped", "fixed", "invalid")
                if (not arg) or (arg not in valid_resolutions):
                    DITrack.Util.common.err(
                        "'close' requires one of %s as the argument" %
                        ", ".join(["'%s'" % x for x in valid_resolutions])
                    )

                try:
                    self._close_issues(arg)
                except _InconsistencyError, e:
                    return self._invalid_action(action, e.issue_id, e.message)

            elif action == "reassign":
                # XXX: this check really belongs to the database layer
                if (not arg) or (arg not in self._dbcfg.users):
                    DITrack.Util.common.err(
                        "'reassign' requires a valid user name as the argument"
                    )

                self._reassign_issues(arg)

            elif action == "reopen":
                if arg:
                    DITrack.Util.common.err(
                        "'reopen' doesn't accept arguments"
                    )

                try:
                    self._reopen_issues()
                except _InconsistencyError, e:
                    return self._invalid_action(action, e.issue_id, e.message)

            elif action == "change-header":
                try:
                    self._change_issues_header(arg)
                except ValueError:
                    DITrack.Util.common.err(
                        "'change-header' requires 'header=value' argument"
                    )

            else:
                DITrack.Util.common.err("Invalid action: %s" % action)

        return self._issue_numbers

class _InteractiveDriver(_Driver):
    """
    Action driver for interactive sessions.
    """

    def _list_attaches(self, issue):
            attaches = issue.attachments()
            qty = len(attaches)
            sys.stdout.write(
                "\n%d file(s) currently attached\n" % qty
            )

            for i in range(qty):

                flags = ""
                if attaches[i].is_local:
                    flags = "L"

                sys.stdout.write(
                    "%3d %-3s %s\n" % (i + 1, flags, attaches[i].name)
                )

            sys.stdout.write("\n")


    def _manage_attaches(self, issue):
        mi_abort = DITrack.UI.MenuItem("a", "abandon this menu")
        mi_new = DITrack.UI.MenuItem("n", "new attach")
        mi_remove = DITrack.UI.MenuItem("r", "remove attach")

        menu = DITrack.UI.Menu(
            "Choose an action to manage attaches",
            [
                mi_abort,
                mi_new,
                mi_remove,
            ])

        while 1:
            # List the attaches
            self._list_attaches(issue)
            
            r = menu.run()

            if r == mi_abort:
                break
            
            elif r == mi_new:
                ti = DITrack.UI.TextInput("File to attach (blank to abort)")

                while 1:
                    fname = ti.run()
                    if not fname:
                        break

                    if not os.path.exists(fname):
                        sys.stdout.write("File doesn't exist: %s\n" % fname)
                        continue

                    if not os.path.isfile(fname):
                        sys.stdout.write("Not a file: %s\n" % fname)
                        continue

                    try:
                        issue.add_attachment(fname)
                        break
                    except ValueError, name:
                        sys.stdout.write(
                            "Attachment named '%s' already exists\n" % name
                        )
                    except DITrack.DB.Exceptions.BadAttachmentNameError, name:
                        sys.stdout.write(
                            "Attachment named '%s' has been removed within "
                            "this session; can't add another one with the "
                            "same name -- you need to save your changes first"
                            "\n"
                            % name
                        )

            elif r == mi_remove:
                # Create menu with a list of attachments
                removal_menu = DITrack.UI.EnumMenu(
                    "Choose an attachment to remove",
                    map(lambda x: x.name, issue.attachments()),
                    abort_option=True
                )

                fname = removal_menu.run()

                if fname is None:
                    continue

                issue.remove_attachment(fname)

                # Go straight to the main menu from here
                return

    def _reply_comment(self):

        assert self._single_issue

        # XXX: shouldn't a 'human-redable' representation of dates be a member
        # of the Comment class?
        def rm_timestamp(text):
            return text.split(" ", 1)[1]

        issue = self._issues[self._issue_numbers[0]]

        # Get only "firm" comments
        comments = issue.comments(local=False)

        # Creating comment choice menu
        mi_reply_abort = DITrack.UI.MenuItem("a", "abort")

        mi_reply_comments = [
            DITrack.UI.MenuItem(
                0,
                "original description by %s, %s" % (
                    issue.info["Opened-by"],
                    rm_timestamp(issue.info["Opened-on"])
                )
            )
        ]

        for (id, c) in comments[1:]:
            mi_reply_comments.append(
                DITrack.UI.MenuItem(
                    int(id),
                    "comment #%s by %s, %s" % (
                        id, c.added_by, rm_timestamp(c.added_on)
                    )
                )
            )

        reply_menu = DITrack.UI.Menu(
            "Choose a comment to reply to",
            [mi_reply_abort] + mi_reply_comments
        )

        comment_id = None

        while comment_id is None:
            r = reply_menu.run()

            if r == mi_reply_abort:
                break
            else:
                # Comment id to reply to
                id = "%d" % r.key          
                c = issue[id]

                sys.stdout.write(
                    "\n======\n"
                    "\nComment #%s by %s, %s\n\n" % (
                        id,
                        c.added_by,
                        rm_timestamp(c.added_on)
                    )
                )
                sys.stdout.write("".join(c.header_as_strings()))
                sys.stdout.write("\n" + c.text + "\n======\n")

                ti_confirmation = DITrack.UI.TextInput(
                    "Is this the comment you'd like to reply to (y/n)?"
                )

                while 1:
                    choice = ti_confirmation.run()
                    if choice == "y":
                        comment_id = id
                        break
                    elif choice == "n":
                        break

        if comment_id is not None:
            c = issue[comment_id]

            def _quote_string(str):
                if str and (str[0] != ">"):
                    return "> " + str
                else:
                    return ">" + str

            self.comment_text = DITrack.Edit.edit_text(
                self._globals,
                "\nQuoting c#%s by %s, %s\n\n%s" % (
                    id,
                    c.added_by,
                    rm_timestamp(c.added_on),
                    "\n".join(map(_quote_string, c.text.split("\n")))
                )
            )

    def run(self):
        """
        If changes are to be saved (see the base class method description),
        the COMMENT_TEXT member contains the comment text for the action upon
        the return from this method.
        """

        # Build up the menu.
        mi_abort = DITrack.UI.MenuItem("a", "abort, discarding changes")
        mi_attaches = DITrack.UI.MenuItem("f", "manage file attaches")
        mi_ch_due_in = DITrack.UI.MenuItem("d", "change due version")
        mi_close = DITrack.UI.MenuItem("c", "close the issue")
        mi_edit_info = DITrack.UI.MenuItem("h", "edit the issue header")
        mi_edit_text = DITrack.UI.MenuItem("e", "edit comment text")
        mi_quit = DITrack.UI.MenuItem("q", "quit, saving changes")
        mi_reassign = DITrack.UI.MenuItem("o", "reassign the issue owner")
        mi_reopen = DITrack.UI.MenuItem("r", "reopen the issue")
        mi_reply = DITrack.UI.MenuItem("re", "reply to a comment")

        menu = DITrack.UI.Menu("Choose an action for the issue(s)",
            [
            mi_abort,
            mi_attaches,
            mi_ch_due_in,
            mi_close,
            mi_edit_info,
            mi_edit_text,
            mi_quit,
            mi_reassign,
            mi_reopen,
            mi_reply
            ])

        save_changes = False

        self.comment_text = ""

        mi_ch_due_in.enabled = self._future_versions
        mi_attaches.enabled = mi_edit_info.enabled = self._single_issue

        while 1:

            # Conditionally enable/disable menu items.
            mi_close.enabled = filter(lambda x: x.info["Status"] == "open",
                self._issues.itervalues())

            mi_reopen.enabled = filter(
                lambda x: x.info["Status"] == "closed",
                self._issues.itervalues()
            )

            mi_reply.enabled = self._single_issue and self.comment_text == ""

            sys.stdout.write("\nActing on:\n")

            output = dict([(x, "") for x in self._vsets])
            for id in self._issue_numbers:
                vset = self._dbcfg.category[
                    self._issues[id].info["Category"]
                ].version_set

                output[vset] += "i#%s: %s\n" % (
                    id, self._issues[id].info["Title"]
                )

            sys.stdout.write("\n")
            for vset in self._vsets:
                sys.stdout.write("[%s]:\n%s\n" % (vset, output[vset]))


            sys.stdout.write("\n")

            r = menu.run()
            if r == mi_abort:
                break

            elif r == mi_attaches:

                assert len(self._issue_numbers) == 1

                self._manage_attaches(self._issues[self._issue_numbers[0]])

            elif r == mi_close:

                mi_c_abort = DITrack.UI.MenuItem("a", "abort closing")
                mi_c_dropped = DITrack.UI.MenuItem("d", "dropped")
                mi_c_fixed = DITrack.UI.MenuItem("f", "fixed")
                mi_c_invalid = DITrack.UI.MenuItem("i", "invalid")

                resolution_menu = DITrack.UI.Menu(
                    "Choose the resolution",
                    [
                        mi_c_abort,
                        mi_c_dropped,
                        mi_c_fixed,
                        mi_c_invalid
                    ])

                r = resolution_menu.run()

                if r != mi_c_abort:
                    if r == mi_c_dropped:
                        resolution = "dropped"
                    elif r == mi_c_fixed:
                        resolution = "fixed"
                    elif r == mi_c_invalid:
                        resolution = "invalid"

                    self._close_issues(resolution)

            elif r == mi_ch_due_in:
                assert self._future_versions

                any_issue = self._issues[self._issue_numbers[0]]
                if self._single_issue:
                    sys.stdout.write("Current due version: %s\n" %
                        any_issue.info["Due-in"])

                due_menu = DITrack.UI.EnumMenu("Choose new due version", 
                        self._future_versions)

                v = due_menu.run()

                if not v: break

                self._change_issues_due_version(v)

            elif r == mi_edit_info:
                id = self._issue_numbers[0]

                info = email.Message.Message()

                keys = self._issues[id].info.keys()
                keys.sort()
                for k in keys:
                    info.add_header(k, self._issues[id].info[k])

                header = DITrack.Edit.edit_text(
                    self._globals, info.as_string()
                )

                self._issues[id].info = {}
                new_info = email.message_from_string(header)
                for h in new_info.keys():
                    self._issues[id].info[h] = new_info[h]

            elif r == mi_edit_text:
                self.comment_text = DITrack.Edit.edit_text(self._globals,
                    self.comment_text
                )

            elif r == mi_quit:
                save_changes = True
                break

            elif r == mi_reassign:

                if self._single_issue:
                    sys.stdout.write("Current issue owner: %s\n" %
                        self._issues[self._issue_numbers[0]].info["Owned-by"]
                    )

                users = self._dbcfg.users.keys()
                users.sort()
                owner_menu = DITrack.UI.EnumMenu("Choose new issue owner",
                    users)

                v = owner_menu.run()

                if not v: break

                self._reassign_issues(v)

            elif r == mi_reopen:
                self._reopen_issues()

            elif r == mi_reply:
                self._reply_comment()

            else:
                raise NotImplementedError

        if save_changes:
            return self._issue_numbers
        else:
            return []


class Handler(DITrack.Command.generic.Handler):
    canonical_name = "act"

    # XXX: replace ISSUENUM with ISSUEID later
    description = """Perform actions on an issue (or multiple issues).
usage: %s ISSUENUM [ISSUENUM...]""" % canonical_name

    description_ps = """
ACTIONLIST is a comma-separated list of one of more of the following actions:

    change-header:HEADER=VALUE
        - add/update issue(s) header HEADER with specified value VALUE;
        omitting VALUE removes the header.

    close:{dropped, fixed, invalid}
        - close the issue(s) with specified resolution.

    change-due:VERSION
        - change the due version to VERSION.

    reassign:USER
        - reassign the issue(s) to USER.

    reopen
        - reopen the issue(s).

Each action can occur in the ACTIONLIST once at the most.

Any of -a, -F or -m implies non-interactive mode.

-F and -m are mutually exclusive.
"""

    def run(self, opts, globals):
        self.check_options(opts)

        if len(opts.fixed) < 2:
            self.print_help(globals)
            sys.exit(1)

        # We'll need an editor.
        globals.get_editor()

        db = DITrack.Util.common.open_db(globals, opts, "w")

        present_vsets = {}

        issue = {}
        prev_issue = {}
        for id in opts.fixed[1:]:

            id = id.upper()
            try:
                issue[id] = db.issue_by_id(id)
            except (KeyError, ValueError):
                # Diagnostics printed by issue_by_id().
                pass

            if db.is_valid_issue_name(id):
                DITrack.Util.common.err(
                    "Non-local identifier expected '%s'" % id
                )

            prev_issue[id] = copy.deepcopy(issue[id])

            present_vsets[
                db.cfg.category[
                issue[id].info["Category"]
                ].version_set] = 1

        # Scan through version sets we are dealing with and find an 
        # intersection of all future versions.
        i = 0
        common_versions = []

        for vset in present_vsets:
            if not i:
                common_versions = db.cfg.versions[vset].future
                i += 1
            else:
                intersected_versions = []
                for version in db.cfg.versions[vset].future:
                    if version in common_versions:
                        intersected_versions.append(version)
                if len(intersected_versions) == 0:
                    break
                common_versions = intersected_versions
        
        common_versions.sort()

        if ("actions" in opts.var) or ("comment_file" in opts.var) or \
            ("comment_message" in opts.var):

            if "actions" in opts.var:
                actions = opts.var["actions"]
            else:
                actions = ""

            if "comment_file" in opts.var:
                comment_text = open(opts.var["comment_file"]).read()
            elif "comment_message" in opts.var:
                comment_text = "%s\n" % opts.var["comment_message"]
            else:
                comment_text = ""

            driver = _CmdlineDriver(
                actions,
                comment_text,
                globals=globals,
                dbcfg=db.cfg,
                issues=issue,
                future_versions=common_versions,
                vsets=present_vsets.keys()
            )
        else:
            driver = _InteractiveDriver(
                globals,
                db.cfg,
                issues=issue,
                future_versions=common_versions,
                vsets=present_vsets.keys()
            )

        issue_numbers = driver.run()

        if issue_numbers:

            # We need to save the changes

            local_names = []

            for id in issue_numbers:

                try:
                    name, comment = db.new_comment(id, prev_issue[id],
                        issue[id], driver.comment_text, globals.username,
                        globals.fmt_timestamp())

                    local_names.append((id, name))

                # XXX: should embrace only db.new_comment()
                except DITrack.DB.Exceptions.NoDifferenceCondition:
                    continue

                sys.stdout.write("Comment %s added to issue %s\n" % (name, id))

            if not opts.var["no_commits"]:
                # Now commit newly added comments. We do it in a separate step
                # to simplify the solution for now. If something goes wrong
                # (like no disk space or connectivity issues), a user may
                # choose to commit the changes later.

                for issue_id, comment_name in local_names:

                    # XXX: for now we don't deal with commenting local issues.
                    assert db.is_valid_issue_number(issue_id)

                    firm_id = db.commit_comment(issue_id, comment_name)

                    # XXX: print 'Local ... in r234'.
                    sys.stdout.write("Local i#%s.%s committed as i#%s.%s\n" % \
                        (issue_id, comment_name, issue_id, firm_id))
