#!/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 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()

    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 process_rules(self):
        """
        Process the rules.
        """
        self.logger.info('Processing rules...')
        inbox = self.config["inbox"]
        for rule in self.rules:
            rule.process(self, inbox)
        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.process_rules()
    p.close()

if __name__ == '__main__':
    main()
