#!/usr/bin/env python

#
# Copyright 2015, Olivier MATZ <zer0@droids-corp.org>
#
# 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.
#     * Neither the name of the University of California, Berkeley nor the
#       names of its contributors may be used to endorse or promote products
#       derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE REGENTS 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 REGENTS AND 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 imapami.actions
import imapami.conditions
import imapami.mail
import imapami.utils

import imaplib
import logging
import re

class ImapamiRule(object):
    """
    A rule is composed of a list of conditions, and a list of actions that are
    executed on the mails which match the conditions.

    Arguments:
      name: <string> [default="no-name"]
        The name of the rule, useful for debug purposes (logs).
      if: <condition config>
        A list of conditions
      do: <action config>
        A list of actions executed when the conditions match
      else-do: <action config>
        A list of actions executed when the conditions do not match
      on-error-do: <action config>
        A list of actions executed on action failure
      fetch: <string> [optional]
        When not specified, the fetch level is deduced from the conditions
        and actions. For instance, if a condition requires to parse the mail
        headers, the fetch level is automatically set to "headers".
        This option allows to force the fetch level for the rule. Valid values
        are: "no", "headers", "part1", and "all".
      inbox: <string> [optional]
        Force the input mailbox for this rule. When not specified, use the
        global inbox configuration.

    Example:
      rules:
      - if:
        - cond1: {cond1_arg1: foo, cond1_arg2: bar}
        - cond2: {cond2_arg1: foo, cond2_arg2: bar}
      - do:
        - action1: {action1_arg1: foo, action1_arg2: bar}
        - action2: {action2_arg1: foo, action2_arg2: bar}
      - else-do:
        - action3: {action3_arg1: foo, action3_arg2: bar}
      - on-error-do:
        - action4: {action4_arg1: foo, action4_arg2: bar}
    """
    def __init__(self, name, condition, match_action, nomatch_action,
                 error_action, fetch=None, inbox=None):
        """
        Initialize a rule.

        :arg string name:
          The name of the rule
        :arg ImapamiCond condition:
          The condition that must match
        :arg ImapamiAction match_action:
          The action to be executed on condition match
        :arg ImapamiAction nomatch_action:
          The action to be executed if condition does not match
        :arg ImapamiAction error_action:
          The action to be executed if an action fails
        :arg string fetch:
          The fetch level if it is forced for this rule (can be "no",
          "headers", "part1", and "all").
        :arg string inbox:
          The input mailbox directory where the rule is executed.
        """
        self.name = name
        self.condition = condition
        self.match_action = match_action
        self.nomatch_action = nomatch_action
        self.error_action = error_action
        if fetch is None:
            fetch = "no"
        elif not fetch in ["no", "headers", "part1", "all"]:
            raise ValueError(
                "rule <%s> invalid fetch directive %s " % (self.name, fetch) +
                "(allowed: no, headers, part1, all)")
        self.fetch = fetch
        self.inbox = inbox

    def get_criteria(self, ami):
        """
        Return the criteria passed to the IMAP search command for this
        rule. It depends on the condition list.

        :arg Imapami ami:
          The Imapami object
        :returns:
          The IMAP search criteria to be sent to the server.
        """
        criteria = self.condition.get_criteria()
        if criteria == '':
            criteria = 'ALL'
        # evaluate variables
        variables = ami.variables
        fmt = imapami.utils.VarFormatter()
        criteria = fmt.format(criteria, **variables)
        return criteria

    def _get_fetch_level(self):
        """
        Return the required fetch level of the message.

        - 'no' means the message is not fetched
        - 'headers' means fetch the mail headers only
        - 'part1' means fetch the headers and the first part of the mail
        - 'all' means fetch all the message including attachments

        The returned value is the highest required fetch level, retrieved
        from condition, actions, and the rule.
        """
        return imapami.utils.highest_fetch_level(
            [self.condition.fetch, self.match_action.fetch,
             self.nomatch_action.fetch, self.error_action.fetch, self.fetch])

    def _search(self, ami, inbox):
        """
        Search the mails on the IMAP server

        :arg Imapami ami:
          The Imapami object
        :arg string inbox:
          The default input mailbox directory.
        :returns:
          A list of IMAP items
        """
        # search messages matching conditions
        criteria = "(%s)" % self.get_criteria(ami)
        ami.logger.debug("processing rule %s, inbox %s, imap criteria %s",
                         self.name, inbox, criteria)
        resp, items = ami.imap.uid("SEARCH", None, criteria)
        if resp != 'OK':
            ami.logger.warning(
                "search failed: server response = %s, skip rule", resp)
            return

        item_list = items[0].split()
        item_list = [i for i in item_list if int(i) < ami.uidnext[inbox]]
        ami.logger.debug("matching mails returned by server: %s", item_list)
        return item_list

    def _get_parts(self, ami):
        """
        Determine which parts of a mail should be fetched

        Depending on the rules, we need to fetch nothing, the headers, the
        first part of the body or all the mail.

        :arg Imapami ami:
          The Imapami object
        :returns:
          A tuple containing:
          - the part string to be passed to the IMAP server
          - a list of (key, IMAP_part)
        """
        fetch = self._get_fetch_level()
        parts = []
        if fetch in ["headers", "part1", "all"]:
            parts.append(("headers", "FLAGS INTERNALDATE BODY.PEEK[HEADER]"))
        if fetch in ["part1", "all"]:
            parts.append(("body_part1", "BODY.PEEK[1]"))
        if fetch == "all":
            parts.append(("body_all", "BODY.PEEK[TEXT]"))
        parts_str = '(%s)' % ' '.join([p[1] for p in parts])
        ami.logger.debug('get imap parts = %s', parts_str)
        return parts_str, parts

    def process(self, ami):
        """
        Process the rule.

        :arg Imapami ami:
          The Imapami object
        """
        if self.inbox is not None:
            inbox = self.inbox
        else:
            inbox = ami.config["inbox"]
        ami.imap.select(inbox)

        # get the list of items (mails) matching the condition criteria
        item_list = self._search(ami, inbox)
        # determine what parts should be fetched
        parts_str, parts = self._get_parts(ami)

        # for each item, fetch it, check the conditions, and do the actions
        for item in item_list:
            mail_data = {'item': item, 'inbox': inbox}
            if parts != []:
                resp, data = ami.imap.uid("FETCH", item, parts_str)
                if resp != 'OK':
                    ami.logger.warning(
                        "search failed: server response = %s, skip item",
                        resp)
                    continue

                # fill mail_data from fetched parts
                for i, part in enumerate(parts):
                    mail_data[part[0]] = data[i][1]

                m = re.match(r'^.*FLAGS \(([^)]*)\) INTERNALDATE ("[^"]*").*$',
                             data[0][0])
                if m is None:
                    ami.logger.warning("cannot parse flags and date %s" %
                                       data[0][0])
                    flags, internal_date = '', ''
                else:
                    flags, internal_date = m.groups()
                mail_data['flags'] = flags
                mail_data['internal_date'] = internal_date

                mail_data['date'] = imaplib.Internaldate2tuple(
                    mail_data['internal_date'])

            mail = imapami.mail.ImapamiMail(**mail_data)
            if self.condition.check(ami, mail) == True:
                ami.logger.debug("item %s matches conditions", item)
                success = self.match_action.process(ami, mail)
            else:
                ami.logger.debug("item %s does not match conditions", item)
                success = self.nomatch_action.process(ami, mail)

            if success == False:
                ami.logger.warning(
                    "at least one action failed for item %s", item)
                self.error_action.process(ami, mail)
        ami.imap.expunge()

def new(config):
    """
    Create a rule object from its yaml config.

    :arg string config:
      The yaml rule config.
    :returns:
      The rule object.
    """
    logger = logging.getLogger('imapami')
    name = config.get("name")
    if name is None:
        name = "no-name"
    fetch = config.get("fetch")
    conditions = config.get("if")
    if conditions is None:
        logger.debug("no condition for rule <%s>, assume always true", name)
        condition = imapami.conditions.ImapamiCond()
    else:
        cond_list = {"and": conditions}
        condition = imapami.conditions.new(cond_list)
    match_actions = config.get("do")
    if match_actions is None:
        logger.info("no action for rule <%s>, will do nothing", name)
        match_action = imapami.actions.ImapamiAction()
    else:
        action_list = {"list": match_actions}
        match_action = imapami.actions.new(action_list)
    nomatch_actions = config.get("else-do")
    if nomatch_actions is None:
        nomatch_action = imapami.actions.ImapamiAction()
    else:
        action_list = {"list": nomatch_actions}
        nomatch_action = imapami.actions.new(action_list)
    error_actions = config.get("on-error-do")
    if error_actions is None:
        error_action = imapami.actions.ImapamiAction()
    else:
        action_list = {"list": error_actions}
        error_action = imapami.actions.new(action_list)
    return ImapamiRule(name, condition, match_action, nomatch_action,
                       error_action, fetch)
