#!/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.rules

import argparse
import getpass
import imaplib
import inspect
import logging
import pydoc
import re
import sys
import yaml

_LOGLEVELS = [logging.CRITICAL, logging.ERROR, logging.WARNING,
              logging.INFO, logging.DEBUG]

class Imapami(object):
    """
    The yaml configuration is a collection (dictionary). The following keys
    are reserved:

    - server: <string> [mandatory]
      Hostname or IP address of the IMAP server to connect to.

    - port: <integer> [optional]
      The TCP port to connect to. If not specified, use the default,
      depending on whether SSL is enabled or disabled.

    - ssl: <boolean> [optional, default is True]
      Enable or disable SSL (True or False).

    - login: <string> [optional]
      IMAP login name. If not specified, the login is asked on stdin.

    - password: <string> [optional]
      IMAP password.  If not specified, the password is asked on stdin.

    - logfile: <string> [optional]
      File where messages are logged. If not specified, no logs are
      written in a file.

    - loglevel: <integer> [optional]
      Level of logs written in logfile. Possible value are from 0 (no log)
      to 4 (debug). Default value is 3 (info).

    - inbox: <string> [optional, default is INBOX]
      Default mailbox directory where rules get message from. It can be
      overriden inside the rule.

    - rules: <list of rules> [mandatory]
      The list of rules to be applied. See the rule syntax for details.

    Any key entry that is not reserved for a configuration parameter is
    saved as a variable. The variables can be used in rules by referencing
    them as {my_var} in conditions or actions arguments.

    Variables are text only, it is possible to test them in rule conditions
    and set them in rule actions.

    """
    def __init__(self, config, loglevel=3):
        """
        Create a Imapami object.

        :arg str config:
          The yaml configuration.
        :arg integer loglevel:
          The level of the console logs.
        """
        self.rules = []
        self.config = {"server": None,
                       "port": None,
                       "ssl": True,
                       "login": None,
                       "password": None,
                       "logfile": None,
                       "loglevel": 3,
                       "inbox": "INBOX",
                       "rules": None}
        self.imap = None
        self.variables = {}
        self.logger = self._get_logger(loglevel)
        self._load_config(config)
        self._update_logger()
        self.uidnext = {}

    def _get_logger(self, loglevel):
        """
        Create a logger, and configure it to log on console. This is done
        before configuration is parsed.

        :arg integer loglevel:
          The level of the console logs from 0 (critical) to 4 (debug).
        """

        if not isinstance(loglevel, int) or loglevel < 0 or loglevel > 4:
            sys.stderr.write("Invalid log level\n")
            raise ValueError

        # create a logger and a stream handler on console
        logger = logging.getLogger('imapami')
        logger.setLevel(logging.DEBUG)
        console_handler = logging.StreamHandler()
        console_handler.setLevel(_LOGLEVELS[loglevel])
        formatter = logging.Formatter('%(levelname)s - %(message)s')
        console_handler.setFormatter(formatter)
        logger.addHandler(console_handler)
        self.console_handler = console_handler
        return logger

    def _update_logger(self):
        """
        Update the logger used after the configuration is parsed: add
        the specified log file in addition to console.
        """
        logfile = self.config.get("logfile")
        # continue to log on console
        if logfile is None:
            return
        # else, add the new file handler
        file_handler = logging.FileHandler(logfile)
        loglevel = self.config.get("loglevel")
        file_handler.setLevel(_LOGLEVELS[loglevel])
        formatter = logging.Formatter(
            '%(asctime)s - %(levelname)s - %(message)s')
        file_handler.setFormatter(formatter)
        self.logger.addHandler(file_handler)

    def _load_config(self, config):
        """
        Load the yaml configuration. It parses the config parameters, the
        variables, and the list of rules.

        :arg str config:
          The yaml configuration.
        """
        yaml_dict = yaml.safe_load(config)
        for key in yaml_dict:
            if key in self.config:
                self.config[key] = yaml_dict[key]
                self.logger.debug("set config %s = %s", key, yaml_dict[key])
            else:
                self.variables[key] = yaml_dict[key]
                self.logger.debug("set variable %s = %s", key, yaml_dict[key])
        for key in ["server", "ssl", "rules"]:
            if self.config[key] is None:
                self.logger.error("%s is not specified in configuration")
                raise ValueError
        rules = self.config["rules"]
        if rules is None:
            self.logger.error("no rule defined")
            raise ValueError
        for i, rule in enumerate(rules):
            try:
                self.rules.append(imapami.rules.new(rule))
            except Exception as e:
                self.logger.error("error while processing rule %d: %s",
                                  i+1, rule.get('name', 'no-name'))
                raise e

    def connect(self):
        """
        Connect and login to the remote IMAP server.
        """
        if self.config.get("ssl") == True:
            imap_class = imaplib.IMAP4_SSL
        else:
            imap_class = imaplib.IMAP4

        login = self.config.get("login")
        if login is None:
            login = raw_input("Username: ")
        password = self.config.get("password")
        if password is None:
            password = getpass.getpass()
        server = self.config.get("server")
        port = self.config.get("port")
        if port is None:
            imap = imap_class(server)
        else:
            imap = imap_class(server, port)
        self.logger.info('Connecting to %s@%s...', login, server)
        imap.login(login, password)
        self.imap = imap

    def get_uidnext(self):
        """
        Get the state (uidnext) for each inbox used in the configuration.
        It gives the uid of the next message the will be added in the mbox.
        We will only care about messages with a uid lower than this uidnext,
        to avoid race conditions with a message arriving while we are in the
        middle of rules processing.
        """
        self.logger.info('Getting inbox state...')
        mboxes = [self.config["inbox"]] + [rule.inbox for rule in self.rules]
        for m in mboxes:
            if m is None:
                continue
            if self.uidnext.get(m, None) is not None:
                continue
            self.imap.select(m)
            typ, dat = self.imap.status(m, "(UIDNEXT)")
            if typ != 'OK':
                raise ValueError("cannot get UIDNEXT: %s", typ)
            match = re.match("[^ ]* \(UIDNEXT ([0-9]+)\)", dat[0])
            if match is None:
                raise ValueError("cannot match UIDNEXT: %s", typ)
            self.uidnext[m] = int(match.groups()[0])
        self.logger.info('Done: %r', self.uidnext)

    def process_rules(self):
        """
        Process the rules.
        """
        self.logger.info('Processing rules...')
        for rule in self.rules:
            rule.process(self)
        self.logger.info('Done.')

    def close(self):
        """
        Close the connection to the IMAP server.
        """
        self.imap.close()
        self.logger.info('Connection closed.')

def show_config_help():
    """
    Show the help related to the configuration file.
    """

    helptxt = """\
The configuration file is in YAML format. Refer to http://yaml.org or
http://pyyaml.org for details about this format.

Full examples can be found in configuration samples located in
imapami/config-samples.

Configuration parameters and variables
======================================

"""
    doc = inspect.getdoc(Imapami)
    helptxt += doc + '\n\n'

    helptxt += 'Rules syntax\n'
    helptxt += '============\n\n'

    doc = inspect.getdoc(imapami.rules.ImapamiRule)
    helptxt += doc + '\n\n'

    conds = imapami.conditions.get()
    conds_list = conds.keys()
    conds_list.sort()
    helptxt += 'Conditions\n'
    helptxt += '==========\n\n'
    for c in conds_list:
        helptxt += "%s\n" % c
        helptxt += "-" * len(c) + '\n\n'
        doc = inspect.getdoc(conds[c])
        if doc is not None:
            helptxt += doc + '\n\n'
        else:
            helptxt += 'No help\n\n'

    actions = imapami.actions.get()
    actions_list = actions.keys()
    actions_list.sort()
    helptxt += 'Actions\n'
    helptxt += '=======\n\n'
    for a in actions:
        helptxt += "%s\n" % a
        helptxt += "-" * len(a) + '\n\n'
        doc = inspect.getdoc(actions[a])
        if doc is not None:
            helptxt += doc + '\n\n'
        else:
            helptxt += 'No help\n\n'
    pydoc.pager(helptxt)

def main():
    """
    Run imapami: parse arguments, and launch imapami.
    """
    parser = argparse.ArgumentParser(
        description='Process emails stored on an IMAP server.')
    parser.add_argument(
        '-c', '--config',
        help='path configuration file (mandatory)')
    parser.add_argument(
        '-C', '--check', action='store_true', default=False,
        help='Only parse configuration and exit')
    parser.add_argument(
        '-H', '--config-help', dest='config_help', action='store_true',
        default=False, help='Show help about configuration file')
    parser.add_argument(
        '-d', '--debug',
        help='Console debug level, from 0 (no output) to 4 (debug).',
        type=int, default=3)

    args = parser.parse_args()
    if args.config_help == True:
        show_config_help()
        sys.exit(0)

    if args.config is None:
        sys.stderr.write('No config file specified\n')
        parser.print_help()
        sys.exit(1)

    p = Imapami(open(args.config).read(), args.debug)
    if args.check == True:
        sys.stderr.write('Configuration parsing ok\n')
        sys.exit(0)

    p.connect()
    p.get_uidnext()
    p.process_rules()
    p.close()

if __name__ == '__main__':
    main()
