#!/usr/bin/python
import os
import shlex
import subprocess
import sys
import re
import tempfile
from l3tlib import SetupError, CommandError, Error
from l3tlib.command import L3tBugCommand
from l3tlib.util import write_to_file, read_file
from l3tlib.template import format_bug


STATUS_OPTIONS = ("NEW", "CONFIRMED", "IN_PROGRESS", "RESOLVED",
                  "REOPENED", "VERIFIED")
RESOLUTION_OPTIONS = ("FIXED", "INVALID", "WONTFIX", "NORESPONSE",
                      "UPSTREAM", "FEATURE", "DUPLICATE", "WORKSFORME",
                      "MOVED")


class EditBug(L3tBugCommand):

    _temporary_file = None

    descr = """\
Edits a given bug

It relies on the credentials from osc to authenticate on the Bugzilla API,
so ensure 'osc ls' works before using this comment. Username and password
can be overridden in the [bugzilla] section of the configuration.

WARNING: As it fetches the latest form from the bug right before submitting
changes, race conditions might happen.

Examples:

    # adds a comment to bug 1047068:
    bze -b 1047068 -c

    # adds a comment and set needinfo:
    bze -b 1047068 -c -n someuser@example.com

    # adds a comment from a file (can be a left over comment file from a
    # previous run of bze that ended in error):
    bze -b 1047068 -c -F /tmp/l3t-Yu_1mj -n someuser@example.com

    # adds a comment and clears needinfo:
    bze -b 1047068 -N -c

    # clears all needinfos set:
    bze -b 1047068 -NN

    # adds a non-private comment to a bug whose Cc list has emails with
    # unknown domains (customers, for example):
    bze -b 1047068 -P -c

    # closes a bug (and a comment is required by bugzilla):
    bze -b 1047068 --status RESOLVED --resolution INVALID -c
"""
    usage = "%prog [-b BUG | BUG | -i INCIDENT]  <operation options>"

    def init_parser(self, parser):
        super(EditBug, self).init_parser(parser)
        parser.add_option("-N", "--clear-needinfo", action="count",
                          default=0,
                          help=("Clear needinfo from the bug (use twice for "
                                "for forcing when more than one needinfo is "
                                "set)"))
        parser.add_option("-n", "--needinfo", default=None,
                          help="Set needinfo to an email address")
        parser.add_option("--description", default=None,
                          help="Set the description (short_desc)")
        parser.add_option("-a", "--assignee", default=None,
                          help="Set assignee to an email address")
        parser.add_option("-c", "--comment", default=False,
                          action="store_true",
                          help="Comment on the bug")
        parser.add_option("-C", "--cc", default=[], action="append",
                          help="Add a user to Cc list")
        parser.add_option("-R", "--remove-cc", default=[], action="append",
                          help="Remove a user from Cc list")
        parser.add_option("--ccme", "--ccmyself", default=False,
                          action="store_true",
                          help="Add yourself to the Cc list")
        parser.add_option("-r", "--reply", default=False,
                          action="store_true",
                          help="Reply to the last comment in the bug")
        parser.add_option("-u", "--url", default=None,
                          help="Set the bug URL")
        parser.add_option("-F", "--file", default=None,
                          help=("Use comment from FILE (when -c or -r"
                                "are used)"))
        parser.add_option("-p", "--private", default=False,
                          action="store_true",
                          help="The comment should be private")
        parser.add_option("-P", "--non-private-is-ok", default=False,
                          action="store_true",
                          help=("Force a non-private comment even when emails "
                                "not from the company are in the CC list"))
        parser.add_option("--priority", default=None,
                          help="Set the bug priority (P{1..5})")
        parser.add_option("--status", default=None,
                          help=("One of %s" % ", ".join(STATUS_OPTIONS)))
        parser.add_option("--resolution", default=None,
                          help=("One of %s" %
                                (", ".join(RESOLUTION_OPTIONS))))
        parser.add_option("--duplicate", default=None, metavar="BUG_ID",
                          help=("Set the duplicate bug when closing with "
                                "--status RESOLVED --resolution DUPLICATE"))
        parser.add_option("--whiteboard", default=None, metavar="PART",
                          dest="whiteboard_add",
                          help=("Append text to the whiteboard"))
        parser.add_option("--whiteboard-remove", default=None,
                          metavar="PART",
                          dest="whiteboard_remove",
                          help=("Remove something from the whiteboard"))

    def parse_args(self, parser, values):
        opts, args = super(EditBug, self).parse_args(parser, values)
        chosen = sum((bool(opts.clear_needinfo),
                      bool(opts.needinfo),
                      bool(opts.comment),
                      bool(opts.reply),
                      bool(opts.file),
                      bool(opts.url),
                      bool(opts.assignee),
                      bool(opts.cc),
                      bool(opts.remove_cc),
                      bool(opts.ccme),
                      bool(opts.priority),
                      bool(opts.description),
                      bool(opts.status),
                      bool(opts.resolution),
                      bool(opts.duplicate),
                      bool(opts.whiteboard_add),
                      bool(opts.whiteboard_remove)))
        if (opts.priority and
                opts.priority not in ("P0", "P1", "P2", "P3", "P4", "P5")):
            parser.error("invalid priority %r" % (opts.priority))
        elif opts.status and opts.status not in STATUS_OPTIONS:
            parser.error("status must be one of %s" %
                         (", ".join(STATUS_OPTIONS)))
        elif opts.resolution and opts.resolution not in RESOLUTION_OPTIONS:
            parser.error("resolution must be one of %s" %
                         (", ".join(RESOLUTION_OPTIONS)))
        elif opts.resolution == "DUPLICATE" and opts.duplicate is None:
            parser.error("--duplicate is needed when --resolution "
                         "DUPLICATE is used")
        elif not chosen:
            parser.error("you must choose at least one action")
        return opts, args

    def _get_file_comment(self):
        try:
            raw = read_file(self.opts.file)
        except EnvironmentError, e:
            raise CommandError("failed to read %s: %s" %
                               (self.opts.file, e))
        comment = self._strip_annotations(raw, expect_mark=False)
        return comment

    def _comment_template(self, bug, aliens, private):
        raw = format_bug(self.config, bug,
                         int(self.config.bugzilla.edit_nlatest),
                         color=False)
        lines = raw.splitlines()
        new = ["", "", self.config.bugzilla.edit_comment_mark]
        if private:
            new.append("# Your comment will be marked as private")
        if aliens:
            new.append("#")
            new.append("# WARNING: beware these emails in the CC list: %s"
                       % (", ".join(aliens)))
            new.append("#")
        new.extend("# " + line for line in lines)
        new.append(self.config.bugzilla.end_comment_mark)
        new.append("")
        new.append("")
        template = "\n".join(new)
        return template

    def _reply_template(self, bug):
        quote = "> "
        comment = bug.comments[-1]
        private = comment.get("private", False)
        text = comment.get("thetext")
        who = comment.get("who")
        ref = len(bug.comments) - 1
        header = ["(In reply to %s from comment %s)" % (who, ref)]
        lines = [quote + line for line in text.splitlines()]
        return "\n".join(header + lines), private

    def _strip_annotations(self, comment, expect_mark=True):
        new = []
        ignore = False
        mark_found = False
        for line in comment.splitlines():
            if line.startswith(self.config.bugzilla.edit_comment_mark):
                ignore = True
                mark_found = True
                if len("".join(new).strip()) == 0:
                    new[:] = []
                continue
            elif line.startswith(self.config.bugzilla.end_comment_mark):
                ignore = False
                continue
            if not ignore:
                new.append(line)
        else:
            if expect_mark and not mark_found:
                raise CommandError("The comment file is missing %r, this "
                                   "is usually a bad sign. Try again."
                                   % (self.config.bugzilla.edit_comment_mark))
        return "\n".join(new)

    def _note_leftover_file(self):
        if self._temporary_file:
            sys.stderr.write("Reuse this comment file with -c -F %s\n" %
                             (self._temporary_file))

    def _remove_comment_file(self):
        if self._temporary_file:
            try:
                os.unlink(self._temporary_file)
            except EnvironmentError:
                pass
            self._temporary_file = None

    def _create_comment_file(self):
        try:
            file = tempfile.NamedTemporaryFile(prefix="l3t-", delete=False)
        except EnvironmentError, e:
            raise SetupError("failed to create temporary file: %s" % (e))
        else:
            self._temporary_file = file.name
        return file

    def _get_comment(self, bug, template):
        cmd = shlex.split(os.getenv("VISUAL", os.getenv("EDITOR", "vi")))
        try:
            file = self._create_comment_file()
            write_to_file(file, template)
            file.close()
        except EnvironmentError, e:
            raise SetupError("failed to write to temporary file: %s" % (e))
        cmd.append(file.name)
        ret = subprocess.call(cmd, shell=False)
        if ret != 0:
            raise CommandError("%s failed with %s. Comment file at %s" %
                               (cmd, ret, file.name))
        try:
            full_comment = read_file(file.name)
        except EnvironmentError, e:
            raise CommandError("failed to read and delete %s: %s" %
                               (file.name, e))
        if full_comment == template:
            self._remove_comment_file()
            raise CommandError("comment file unchanged")
        comment = self._strip_annotations(full_comment)
        if not comment.strip():
            raise CommandError("the comment is empty")
        return comment

    def _get_aliens(self, bug):
        expr = re.compile(self.config.l3t.native_domains)
        aliens = [email for email in bug.cc_list
                  if expr.search(email) is None]
        return aliens

    def _check_aliens(self, aliens):
        if (aliens and not self.opts.private
                and not self.opts.non_private_is_ok):
            emails = ", ".join(aliens)
            raise CommandError("refusing to make a non-private "
                               "comment when there are "
                               "non-native email addresses (%s) in the "
                               "CC list; review it and use -P to "
                               "comment anyway or -p to make it private."
                               % (emails))
        return aliens

    def _handle_comment_options(self, bug):
        private = self.opts.private
        comment = None
        if self.opts.file or self.opts.comment or self.opts.reply:
            aliens = self._get_aliens(bug)
            self._check_aliens(aliens)
            if self.opts.file:
                comment = self._get_file_comment()
            else:
                if self.opts.reply:
                    reply_template, private = self._reply_template(bug)
                    comment_template = self._comment_template(bug, aliens,
                                                              private)
                    template = reply_template + comment_template
                else:  # comment
                    template = self._comment_template(bug, aliens, private)
                comment = self._get_comment(bug, template)
        return comment, private

    def run(self):
        try:
            bug = self.l3t.get_bug(self.opts.incident, self.opts.bug)
            comment, is_private = self._handle_comment_options(bug)
            clear_all_needinfos = (self.opts.clear_needinfo > 1)
            self.l3t.update_bug(bug.bug_id, add_comment=comment,
                                clear_needinfo=bool(self.opts.clear_needinfo),
                                clear_all_needinfos=clear_all_needinfos,
                                set_needinfo=self.opts.needinfo,
                                set_url=self.opts.url,
                                set_assignee=self.opts.assignee,
                                add_cc=self.opts.cc,
                                remove_cc=self.opts.remove_cc,
                                cc_myself=self.opts.ccme,
                                private=is_private,
                                priority=self.opts.priority,
                                description=self.opts.description,
                                status=self.opts.status,
                                resolution=self.opts.resolution,
                                duplicate=self.opts.duplicate,
                                delta_ts=bug.delta_ts,
                                whiteboard_add=self.opts.whiteboard_add,
                                whiteboard_remove=self.opts.whiteboard_remove)
        except Error:
            self._note_leftover_file()
            raise
        else:
            self._remove_comment_file()


EditBug().main()
