import smtplib
import base64
import email.parser
import email.utils
import email.message
import datetime
import logging
import time

from google.appengine.api import mail as gae_mail

import imapclient
import imaplib2

IMAPError = imaplib2.IMAP4.error


class Error(Exception):
    """ base error class """
    # True if we cannot recover from this error by retrying the action.
    persistent = True


class MailboxNotFound(Error):
    """ Mailbox that was expected to exist was not found """


class AuthenticationError(Error):
    """ Authentication failed """
    persistent = False


class InvalidEmail(Error):
    """ Invalid email (eg no from address) """


class RfcMsgIdMissing(Error):
    """ Invalid email (eg no from address) """


class MailNotFound(Error):
    """ Mail was not found """


class Mail(object):
    def __init__(self, rfc_str, labels=None):
        self.rfc_message = rfc_str
        if labels is None:
            labels = []
        self.labels = labels

    def get_parsed_message(self):
        """ Return parsed copy of message (email.message.Message). """
        return email.parser.Parser().parsestr(self.rfc_message)


def xoauth2_bearer(username, access_token):
    """ Returns b64 encoded username and access token for oauth2 login. """
    bearer = 'user=%s\1auth=Bearer %s\1\1' % (username, access_token)
    return base64.b64encode(bearer)


def make_message_id():
    """
    Generates rfc message id. The returned message id includes the angle
    brackets.
    """
    return email.utils.make_msgid('sndlatr')


class IMAPSession(object):
    def __init__(self, user, access_token):
        self._selected_folder = None
        self.client = imapclient.IMAPClient('imap.gmail.com', use_uid=True,
                                            ssl=True)
        if 'COMPRESS=DEFLATE' in self.client.capabilities:
            self.client.enable_compression()
            # WARNING: never set enable this in productive environment
        # It has a bug.
        self.client.debug = 0
        try:
            self.client.oauth2_login(user, access_token)
        except IMAPError, e:
            self.quit()
            raise AuthenticationError('oauth login failed: ' + str(e))
        try:
            self._list_folders()
            # make sure it fails early if those mailboxes do not exist.
            self.get_trash_box()
            self.get_all_box()
        except Exception:
            self.quit()
            raise

    def _list_folders(self):
        """
        List folders folders and stores mapping of box to list of
        flags in dict self.boxes_flags.
        """
        folders = self.client.list_folders()
        self.boxes_flags = {box: flags for (flags, _, box) in folders}

    def get_all_box(self):
        """ Returns name of special All mailbox """
        return self.box_with_flag('\All')

    def get_trash_box(self):
        """ Returns name of special All mailbox """
        return self.box_with_flag('\Trash')

    def select_folder(self, mailbox):
        """
        Select folder. This remembers the folder selected and only
        sends a command to the server if it differs.
        """
        if mailbox != self._selected_folder:
            self._selected_folder = mailbox
            self.client.select_folder(mailbox)

    def delete_message(self, message_id):
        """ Deletes draft with given message id completely.
        """
        client = self.client
        self.select_folder(self.get_all_box())
        uid = self.uid_by_message_id(message_id)
        if not uid:
            raise MailNotFound(
                'mail with id {} not found'.format(message_id))

        # labels = client.get_gmail_labels()
        client.remove_gmail_labels(uid, ['\\Draft'])
        client.add_gmail_labels(uid, ['\\Trash'])
        self.select_folder(self.get_trash_box())
        uid = self.uid_by_message_id(message_id)
        if uid is None:
            raise Error()
        client.delete_messages([uid])
        client.expunge()
        # sent_label = '\\Sent'
        # drafts_label = '\\Draft'
        # client.add_gmail_labels(uid, [sent_label])

    def copy_labels_to_sent(self, rfc_message_id, labels):
        """
        Copies given labels -Draft, +Sent to message with given rfcMessageId
        """
        self.select_folder(self.get_all_box())
        uid = self.uids_by_rfc_message_id(rfc_message_id)
        if uid is not None:
            labels = set(labels)
            # sent label will not be there for re-uploaded messages
            # labels.add('\\Sent')
            labels.discard('\\Draft')
            if labels:
                self.client.add_gmail_labels(uid, labels=list(labels))
        else:
            logging.warning('could not find sent message {}, ignoring'.format(
                rfc_message_id))

    def mark_as_sent(self, message_id, rfc_message_id, mail=None):
        # make sure we can find sent message
        self.client.noop()
        # copy labels to sent message
        if mail is None:
            mail = self.get_mail(message_id)
            if mail is None:
                # mail seems to have been deleted in the meantime,
                # we don't treat this as an error
                logging.warning(
                    'mail was deleted between send '
                    'and mark_as_sent: {}'.format(message_id))
                return

        self.copy_labels_to_sent(rfc_message_id, mail.labels)
        # delete original message
        self.delete_message(message_id)

    def uids_by_rfc_message_id(self, rfc_msg_id):
        """
        Get uid of message with given Rfc message id
        """
        return self.raw_search('rfc822msgid:{}'.format(rfc_msg_id))

    def get_thread(self, thread_id, limit=None, reverse=False):
        """
        Returns unorderd list of metadata of messages in given thread.
        Each item is a dict with the following keys:
        message_id, rfc_message_id, subject, from_name,
        from_email, date, in_reply_to, references.
        Values correspond to the mail header values.
        message_id is the internal gmail message id as hex string.
        """
        self.select_folder(self.get_all_box())
        uids = self.client.search('X-GM-THRID {:d}'.format(thread_id))
        if reverse:
            uids = list(reversed(uids))
        if limit is not None:
            uids = uids[0:limit]
        result = self.client.fetch(uids, ['X-GM-MSGID', 'RFC822.HEADER'])
        # restore original order
        result = [result[uid] for uid in uids]
        msgs = []
        for item in result:
            mail = email.parser.Parser().parsestr(item['RFC822.HEADER'])
            id = '{:x}'.format(int(item['X-GM-MSGID']))
            msg = {'message_id': id,
                   'rfc_message_id': mail.get('message-id'),
                   'in_reply_to': mail.get('in-reply-to'),
                   'references': mail.get('references'),
                   'subject': mail.get('subject')}
            if 'date' in mail:
                date = email.utils.parsedate_tz(mail['date'])
                if date:
                    msg['date'] = datetime.datetime.fromtimestamp(
                        email.utils.mktime_tz(date))
            if 'from' in mail:
                name, addr = email.utils.parseaddr(mail['from'])
                msg['from_name'] = name
                msg['from_email'] = addr
            msgs.append(msg)
        return msgs

    def box_with_flag(self, flag):
        """
        Returns mailbox that has given flag. Raises MailboxNotFound if no such
        mailbox exists.
        """
        for box, flags in self.boxes_flags.iteritems():
            if flag in flags:
                return box
        raise MailboxNotFound(
            'Mailbox with flag {} does not exist'.format(flag))

    def uid_by_message_id(self, message_id):
        """ Search mail by message_id """
        uids = self.client.search('X-GM-MSGID {:d}'.format(message_id))
        if not uids:
            return None
        return uids[0]

    def raw_search(self, query):
        return self.client.search(u'X-GM-RAW {}'.format(query))

    def get_mail(self, message_id):
        """
        Get mails as rfc822 encoded string by message_id (64 bit integer).
        Returns None if mail is not found.
        """
        # select all-mails mailbox
        self.select_folder(self.get_all_box())
        uid = self.uid_by_message_id(message_id)
        if not uid:
            return None
            # search by message id (int format needed for imap)
        messages = self.client.fetch(uid, ['RFC822', 'X-GM-LABELS'])
        msg = messages.get(uid)
        if not msg:
            return None

        return Mail(msg['RFC822'], msg['X-GM-LABELS'])

    def quit(self):
        try:
            self.client.logout()
        except (IMAPError, IOError):
            logging.warn('error during imap.quit(), ignoring', exc_info=True)
            pass


class SMTPSession(object):
    def __init__(self, user, access_token):
        self.client = smtplib.SMTP_SSL('smtp.gmail.com')
        # self.client.set_debuglevel(4)
        self._login(user, access_token)

    def _login(self, user, access_token):
        """ Login with oauth2 token. Raises
        """
        self.client.ehlo_or_helo_if_needed()
        bearer = xoauth2_bearer(user, access_token)
        (code, resp) = self.client.docmd('AUTH XOAUTH2 {}'.format(bearer))
        if code not in (235, 503):
            # 235 == 'Authentication successful'
            # 503 == 'Error: already authenticated'
            raise AuthenticationError(
                'SMTP auth failed with code {}: {}'.format(code, resp))

    def send_rfc822(self, rfc822_mail):
        """
        Send rfc822 mail as fetched via imap. To and from addresses are
        extracted from the rfc822 envolope.
        Returns the rfc message id of the sent message.
        """
        rewriter = MailSendRewriter(rfc822_mail)
        receivers = rewriter.get_receivers()
        rewriter.rewrite()
        if not receivers:
            raise InvalidEmail('no to address')
            # TODO: check for any rejected recepient. Fail if any fails?
        try:
            self.client.sendmail(rewriter.get_from(), receivers,
                                 rewriter.message_as_str())
        except smtplib.SMTPRecipientsRefused:
            raise InvalidEmail('server rejected recepients')

    def quit(self):
        self.client.quit()


class MailSendRewriter(object):
    """
    Helper class to rewrite a rfc822 mail that was fetched via imap to
    prepare it for sending via SMTP.
    """

    def __init__(self, mail_rfc822):
        if isinstance(mail_rfc822, email.message.Message):
            self.message = mail_rfc822
        else:
            self.message = email.parser.Parser().parsestr(mail_rfc822)

    def rewrite(self):
        """ Performs the following modifications to message headers:
        - Updates date in message to current date
        - Removes Message-ID
        """
        message = self.message
        # gmail does not sent if there is a existing message id
        if 'Date' in message:
            del message['Date']
        if 'bcc' in message:
            del message['bcc']
        message['Date'] = email.utils.formatdate()

    def get_from(self):
        """ Returns email address of sender. Raises InvalidEmail if there is
        no from header or it cannot be parsed."""
        from_addr = str(self.message.get('from'))
        try:
            return email.utils.parseaddr(from_addr)[1]
        except AttributeError:
            raise InvalidEmail('missing from address')

    def get_bcc(self):
        """  Returns list of (realname, address) tuples in bcc field. """
        return email.utils.getaddresses(
            str(addr) for addr in self.message.get_all('bcc', []))

    def generate_receivers(self):
        """
        Generator for mails that have to be sent out individually.
        Yields a sequence of receiver lists. Before each receiver list is
        yielded, it modifies self.message to reflect the message headers that
        should be used for that message. It does yield copies of the message
        for memory efficiency reasons.
        It yields a 1-item list of receivers for every bcc entry.
        The message is modified to contain only this bcc header.
        All non-bcc-receivers are yielded as a single multi-item list
        The bcc header is removed when this list is yielded.
        Intened to be used like this::

            for receivers in rewriter.generate_receivers():
                sendmail(receivers, rewriter.message_as_str())
        """
        all_receivers = self.get_receivers()
        bccs = self.get_bcc()
        # email addresses only
        bcc_mails = [addr[1] for addr in bccs]

        bulk_receivers = [m for m in all_receivers if m not in bcc_mails]
        if bulk_receivers:
            self.rewrite()
            yield bulk_receivers

        for name, addr in bccs:
            self.rewrite()
            self.message['bcc'] = email.utils.formataddr((name, addr))
            yield [addr]

    def get_receivers(self):
        """
        List of all receiver email-addresses (without the name parts).
        This includes addresses from the TO, CC and BCC headers.
        """
        message = self.message
        to_addrs = (message.get_all('to', []) +
                    message.get_all('cc', []) +
                    message.get_all('bcc', []))
        to_addrs = [str(addr) for addr in to_addrs]

        to_addrs = email.utils.getaddresses(to_addrs)
        # addresses only without name parts
        return [addr[1] for addr in to_addrs]

    def message_as_str(self):
        """ RFC822 encoded message """
        return self.message.as_string()


class Mailman(object):
    """
    Our main utility class to send email. Interacts with imap and smtp
    sessions
    """

    def __init__(self, user, auth_token):
        self.user = user
        self.auth_token = auth_token
        self._imap_session = None

    def open_imap_session(self):
        """ Returns opened imap session. """
        return IMAPSession(self.user, self.auth_token)

    def open_smtp_session(self):
        """ Returns opened smtp session. """
        return SMTPSession(self.user, self.auth_token)

    def get_opened_imap_session(self):
        """
        Returns imap session. Opens new session if there is no opened
        session yet.
        """
        imap = self._imap_session
        if imap is None:
            imap = self.open_imap_session()
            self._imap_session = imap
        return imap

    def safe_smtp_quit(self, smtp):
        try:
            smtp.quit()
        except (smtplib.SMTPException, IOError):
            logging.warn('error during smtp.quit(), ignoring', exc_info=True)
            # ignore
            pass

    def send_draft(self, message_id, send_rfc_message_id):
        """
        Fetches message with given id from imap and sends it using smtp.
        Returns the original mail as a gmail.Mail object.
        """
        imap = self.get_opened_imap_session()
        mail = imap.get_mail(message_id)
        if mail is None:
            raise MailNotFound('Mail with id {} not found'.format(message_id))
        message = mail.get_parsed_message()
        if 'Message-Id' in message:
            del message['Message-Id']
        message['Message-Id'] = send_rfc_message_id

        smtp = self.open_smtp_session()
        smtp.send_rfc822(message)
        self.safe_smtp_quit(smtp)
        return mail

    def mark_as_sent(self, message_id, sent_message_rfc_id, mail=None):
        """  moves message from drafts to sent folder """
        imap = self.get_opened_imap_session()
        imap.mark_as_sent(message_id, sent_message_rfc_id, mail=mail)

    def get_thread(self, thread_id, **kwargs):
        """ See IMAPSession.get_thread. """
        return self.get_opened_imap_session().get_thread(thread_id, **kwargs)

    def build_reply(self, thread_id, mail=None):
        """
        Modify mail (gae mail.EmailMessage) to a reply of the given thread_id.
        If mail is None, creates a new, empty EmailMessage object.
        This looks up the last message in the given thread and modifies
        the References and Reply-To and subject headers of mail to be a reply
        to this message.
        """
        if mail is None:
            mail = gae_mail.EmailMessage()
        orig_msg = self.get_thread(thread_id, reverse=True, limit=1)
        if not orig_msg:
            raise MailNotFound('thread not found')
        orig_msg = orig_msg[0]

        # set References and In-Reply-To (see rfc rfc2822 3.6.4)
        orig_msg_id = orig_msg.get('rfc_message_id')
        if not orig_msg_id:
            raise RfcMsgIdMissing('Last mail in thread has no rfc message id')
        orig_references = orig_msg.get('references')
        if not orig_references:
            orig_references = orig_msg.get('in_reply_to')
        if not orig_references:
            orig_references = ''

        mail.headers = {'References': orig_references + '\r\n ' + orig_msg_id,
                        'In-Reply-To': orig_msg_id}
        mail.subject = orig_msg['subject']
        return mail

    def send_mail(self, mail):
        """ Sends gae mail.EmailMessage via SMTP. """
        smtp = self.open_smtp_session()
        smtp.send_rfc822(mail.to_mime_message())
        self.safe_smtp_quit(smtp)

    def quit(self):
        imap = self._imap_session
        if imap is not None:
            imap.quit()
            self._imap_session = None