#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2017 Chris Cummins <chrisc.101@gmail.com>.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
"""
let me know - Email output of command upon completion

Attributes
----------
__version__ : str
    Package version.

__description__ : str
    Package description.

DEFAULT_CFG : str
    Default configuration.

DEFAULT_CFG_PATH : str
    Default path to the configuration file.

E_CFG : int
    Non-zero return code for configuration errors.

E_SMTP : int
    Non-zero return code for fatal SMTP errors.
"""
from __future__ import print_function

import argparse
import os
import pipes
import shlex
import smtplib
import string
import subprocess
import sys

from datetime import datetime
from pkg_resources import require
from socket import gethostname

if sys.version_info >= (3, 0):
    from email.mime.multipart import MIMEMultipart
    from email.mime.text import MIMEText
else:
    from email.MIMEMultipart import MIMEMultipart
    from email.MIMEText import MIMEText

__version__ = require("lmk")[0].version

__description__ = """\
lmk {__version__}: let me know. Patiently awaits the completion of the
specified comamnd, and emails you with the output and result.

Made with \033[1;31m♥\033[0;0m by Chris Cummins.
<https://github.com/ChrisCummins/lmk>\
""".format(**vars())

DEFAULT_CFG = """\
; lkm 'let me know' config file
; <https://github.com/ChrisCummins/lmk>
;
; Notes:
;   * Shell $VARIABLES are expanded, use '\$' to escape '$' symbol.
;   * Special variable $HOST expands to system hostname.
;   * 'To' field of messages may be a comma separated list of recipients.
;   * Subject line may use special variables $EXIT (exit status of command) and
;       $CMD (the command executed).
;
[smtp]
Host: smtp.gmail.com
Port: 587
Username: $LMK_USER
Password: $LMK_PWD

[messages]
From: $USER@$HOST
To: $LMK_TO
Subject: $USER@$HOST [$EXIT_EMOJI] \$ $CMD
"""
DEFAULT_CFG_PATH = os.path.expanduser('~/.lmk.cfg')

E_CFG = 2
E_SMTP = 3


class colors:
    """
    Shell escape codes.
    """
    reset = '\033[0;0m'
    red = '\033[1;31m'
    blue = '\033[1;34m'
    cyan = '\033[1;36m'
    green = '\033[0;32m'
    bold = '\033[;1m'
    reverse = '\033[;7m'


class ArgumentParser(argparse.ArgumentParser):
    """
    Specialized argument parser, with --version flag.
    """

    def __init__(self, *args, **kwargs):
        """
        See python argparse.ArgumentParser.__init__().
        """
        super(ArgumentParser, self).__init__(*args, **kwargs)
        self.add_argument(
            '--version',
            action='store_true',
            help='show version information and exit')

    def parse_args(self, args=sys.argv[1:], namespace=None):
        """
        See python argparse.ArgumentParser.parse_args().
        """
        # --version option overrides the normal argument parsing process.
        c = colors
        version = __version__
        if '--version' in args:
            print('lnk {version}, made with {c.red}♥{c.reset} by '
                  'Chris Cummins <chrisc.101@gmail.com>'.format(**vars()))
            sys.exit(0)

        return super(ArgumentParser, self).parse_args(args, namespace)


def parse_args(args):
    """
    Parse command line options.

    Returns
    -------
    str
        Command to execute.
    """
    parser = ArgumentParser(
        description=__description__,
        formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument(
        'command', metavar='<command>', help='command to execute')
    args = parser.parse_args(args)
    return args.command


def create_default_cfg(path):
    """
    Create default configuration file.

    Parameters
    ----------
    path : str
        Path of cfg file to create.
    """
    with open(path, 'w') as outfile:
        print(DEFAULT_CFG, end='', file=outfile)
    c = colors
    print(
        '{c.bold}[lmk] created default configuration file {path}{c.reset}'
        .format(**vars()),
        file=sys.stderr)


def parse_str(str_, substitutions={}):
    """
    Parse a string, escaping shell and special variables.

    Rudimentary, crummy bash variable parser.

    Parameters
    ----------
    str_ : str
        String to parse.
    substitutions : Dict[str, lambda: str]
        A dictionary of substitution functions.
    """

    def expandvar():
        if ''.join(varname) in substitutions:
            var = substitutions[''.join(varname)]()
        else:
            var = os.environ.get(''.join(varname), '')
        out.append(var)

    BASH_VAR_CHARS = string.ascii_letters + string.digits + '_'

    # parser state
    out = []
    varname = []
    invar = False
    escape = False

    for c in str_:
        if c == '\\':
            if escape:
                # '\\' -> '\'
                out.append('\\')
                escape = False
            else:
                escape = True
        elif c == '$':
            if escape:
                # '\$' -> '$'
                out.append('$')
                escape = False
            else:
                if invar:
                    # '$foo$bar' -> $(foo) $(bar)
                    expandvar()
                varname = []
                invar = True
        elif c == ' ':
            escape = False
            if invar:
                # '$foo ' -> $(foo)' '
                expandvar()
                varname = []
                invar = False
            out.append(' ')
        else:
            if invar:
                if c in BASH_VAR_CHARS:
                    varname.append(c)
                else:
                    # '$foo@' -> $(foo)'@'
                    expandvar()
                    varname = []
                    invar = False
                    out.append(c)
            else:
                escape = False
                out.append(c)

    if invar:
        expandvar()
    return ''.join(out)


def load_cfg(path):
    """
    Parse configuration.

    In case of error, kills process with status E_CFG.

    Returns
    -------
    ConfigParser
        Parsed configuration.
    """

    def _verify(stmt, *msg, **kwargs):
        sep = kwargs.get('sep', ' ')
        msg = sep.join(msg)
        c = colors
        if not stmt:
            print(
                '{c.bold}{c.red}[lmk] {msg}{c.reset}'.format(**vars()),
                file=sys.stderr)
            sys.exit(E_CFG)

    if sys.version_info >= (3, 0):
        from configparser import ConfigParser
    else:
        from configparser import ConfigParser

    cfg = ConfigParser()
    cfg.read(path)

    _verify('smtp' in cfg, 'config file %s contains no [smtp] section' % path)
    _verify('host' in cfg['smtp'], 'no host in %s:smtp' % path)
    _verify('port' in cfg['smtp'], 'no port in %s:smtp' % path)
    _verify('username' in cfg['smtp'], 'no username in %s:smtp' % path)
    _verify('password' in cfg['smtp'], 'no password in %s:smtp' % path)

    _verify('messages' in cfg,
            'config file %s contains no [messages] section' % path)
    _verify('from' in cfg['messages'], 'no from address in %s:messages' % path)
    _verify('to' in cfg['messages'], 'no to address in %s:messages' % path)

    parse = lambda x: parse_str(x, {'HOST': lambda: gethostname()})

    cfg['smtp']['host'] = parse(cfg['smtp']['host'])
    cfg['smtp']['port'] = parse(cfg['smtp']['port'])
    cfg['smtp']['username'] = parse(cfg['smtp']['username'])
    cfg['smtp']['password'] = parse(cfg['smtp']['password'])

    _verify(cfg['smtp']['host'], 'stmp host is empty. Check %s' % path)
    _verify(cfg['smtp']['port'], 'stmp port is empty. Check %s' % path)
    _verify(cfg['smtp']['username'], 'stmp username is empty. Check %s' % path)
    _verify(cfg['smtp']['password'], 'stmp password is empty. Check %s' % path)

    cfg['messages']['from'] = parse(cfg['messages']['from'])
    cfg['messages']['to'] = parse(cfg['messages']['to'])
    # note: cfg["messages"]["subject"] is parsed after command completion,
    #   so we can substitue in outcomes.

    # add runtime metadata
    cfg.add_section('/run')
    cfg['/run']['path'] = path

    return cfg


def get_smtp_server(cfg):
    """
    Create a connection an SMTP server.

    In case of an error, this function kills the process.
    Remove to close connections with quit().

    Parameters
    ----------
    cfg : ConfigParser
        Configuration.

    Returns
    -------
    SMTP
        SMTP Server.
    """

    def _error(*msg, **kwargs):
        sep = kwargs.get('sep', ' ')
        msg = sep.join(msg)
        print(
            '{c.bold}{c.red}[lmk] {msg}{c.reset}'.format(**vars()),
            file=sys.stderr)
        sys.exit(E_SMTP)

    c = colors
    try:
        server = smtplib.SMTP(cfg['smtp']['host'], int(cfg['smtp']['port']))
        server.starttls()
        server.login(cfg['smtp']['username'], cfg['smtp']['password'])
        return server
    except smptlib.SMTPHeloError:
        host, port = cfg['smtp']['host'], cfg['smtp']['port']
        _error('connection to {host}:{port} failed'.format(**vars()))
    except smtplib.SMTPAuthenticationError:
        _error('smtp authentication failed. Check username and password in '
               '%s' % cfg['/run']['path'])
    except smtplib.SMTPServerDisconnected:
        host, port = cfg['smtp']['host'], cfg['smtp']['port']
        cfg_path = cfg['/run']['path']
        _error(
            '{host}:{port} disconnected. Check smtp settings in {cfg_path}'
            .format(**vars()),
            file=sys.stderr)
    except smptlib.SMTPException:
        host, port = cfg['smtp']['host'], cfg['smtp']['port']
        cfg_path = cfg['/run']['path']
        _error('unknown error from {host}:{port}'.format(**vars()))


def send_email_smtp(cfg, server, msg):
    """
    Send an email.

    Parameters
    ----------
    server : SMTP
        SMTP server.
    msg : MIMEMultipart
        Message to send.

    Returns
    -------
    bool
        True is send suceeded, else false.
    """

    def _error(*msg, **kwargs):
        sep = kwargs.get('sep', ' ')
        msg = sep.join(msg)
        print(
            '{c.bold}{c.red}[lmk] {msg}{c.reset}'.format(**vars()),
            file=sys.stderr)
        return False

    c = colors
    try:
        recipient = msg['To']
        server.sendmail(msg['From'], msg['To'], msg.as_string())
        print(
            '{c.bold}{c.cyan}[lmk] {recipient} notified{c.reset}'.format(
                **vars()),
            file=sys.stderr)
        return True
    except smtplib.SMTPHeloError:
        host, port = cfg['smtp']['host'], cfg['smtp']['port']
        return _error('connection to {host}:{port} failed'.format(**vars()))
    except smtplib.SMTPDataError:
        host, port = cfg['smtp']['host'], cfg['smtp']['port']
        return _error('unknown error from {host}:{port}'.format(**vars()))
    except smtplib.SMTPRecipientsRefused:
        return _error('recipient {recipient} refused'.format(**vars()))
    except smtplib.SMTPSenderRefused:
        from_ = msg['From']
        return _error('sender {from_} refused'.format(**vars()))
    return False


def build_html_message_body(command, output, returncode, runtime):
    """
    Parameters
    ----------
    command : str
        The command which was run.
    output : str
        The output of the command.
    runtime : Tuple(datetime, datetime)
        The command start and end dates.

    Returns
    -------
    str
        HTML string.
    """
    user = os.environ['USER']
    host = gethostname()
    cwd = os.getcwd()
    lmk_version = __version__
    date_started, date_ended = runtime
    lmk = '<a href="github.com/ChrisCummins/lmk">lmk</a>'
    me = '<a href="http://chriscummins.cc">Chris Cummins</a>'

    import cgi
    command_html = cgi.escape(command)

    prompt_css = ";".join([
        "font-family:'Courier New', monospace",
        "font-weight:700",
        "font-size:14px",
        "padding-right:10px",
        "color:#000",
        "text-align:right",
    ])

    command_css = ";".join([
        "font-family:'Courier New', monospace",
        "font-weight:700",
        "font-size:14px",
        "color:#000",
    ])

    lineno_css = ";".join([
        "font-family:'Courier New', monospace",
        "font-size:14px",
        "padding-right:10px",
        "color:#666",
        "text-align:right",
    ])

    line_css = ";".join([
        "font-family:'Courier New', monospace",
        "font-size:14px",
        "color:#000",
    ])

    html = u"""<table>
  <tr style="line-height:1em;">
    <td style="{prompt_css}">$</td>
    <td style="{command_css}">{command_html}</td>
  </tr>
""".format(**vars())

    for i, line in enumerate(output.split('\n')):
        lineno = i + 1
        line_html = cgi.escape(line)
        html += """\
  <tr style="line-height:1em;">
    <td style="{lineno_css}">{lineno}</td>
    <td style="{line_css}">{line_html}</td>
  </tr>
""".format(**vars())

    html += u"""\
</table>

<hr style="margin-top:20px;"/>
<table>
  <tr><td>Return code</td><td style="font-weight:700;">{returncode}</td></tr>
  <tr><td>Host</td><td>{user}@{host}</td></tr>
  <tr><td>Working directory</td><td>{cwd}</td></tr>
  <tr><td>Started</td><td>{date_started}</td></tr>
  <tr><td>Completed</td><td>{date_ended}</td></tr>
</table>

<center style="color:#626262;">
  {lmk} {lmk_version} made with ♥ by {me}
</center>
""".format(**vars())
    return html


def get_cfg_path():
    """
    Get path to config file.

    If config file not found, kills the process with E_CFG.

    Returns
    -------
    str
        Config path.
    """
    cfg_path = os.path.expanduser(os.environ.get('LMK_CFG', DEFAULT_CFG_PATH))
    if not os.path.exists(cfg_path) and cfg_path == DEFAULT_CFG_PATH:
        create_default_cfg(cfg_path)
    elif not os.path.exists(cfg_path):
        print(
            '{c.bold}{c.red}$LMK_CFG ({cfg_path}) not found{c.reset}'.format(
                **vars()),
            file=sys.stderr)
        sys.exit(E_CFG)
    return cfg_path


def main():
    command = parse_args(sys.argv[1:])

    cfg = load_cfg(get_cfg_path())

    # test the connection
    get_smtp_server(cfg).quit()

    date_started = datetime.now()

    out = []
    process = subprocess.Popen(
        command,
        shell=True,
        universal_newlines=True,
        bufsize=1,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT)

    if sys.version_info >= (3, 0):
        output_iter = process.stdout
    else:
        output_iter = iter(process.stdout.readline, b'')

    with process.stdout:
        for line in output_iter:
            print(line, end='')
            out.append(line)
    process.wait()

    exit_status = process.returncode
    exit_tick_cross = u'✘ ' + str(exit_status) if exit_status else u'✔'
    exit_emoji = u'🙈' if exit_status else u'🙂'
    output = ''.join(out).strip()
    date_ended = datetime.now()

    html = build_html_message_body(
        command, output, exit_status, (date_started, date_ended))
    if sys.version_info < (3, 0):
        html = html.encode('utf-8')

    msg = MIMEMultipart()
    msg['From'] = cfg['messages']['from']
    msg['Subject'] = parse_str(cfg['messages']['subject'], {
        'HOST': lambda: gethostname(),
        'EXIT': lambda: str(exit_status),
        'EXIT_TICK_CROSS': lambda: exit_tick_cross,
        'EXIT_EMOJI': lambda: exit_emoji,
        'CMD': lambda: command,
        'DATE_START': lambda: str(date_started),
        'DATE_END': lambda: str(date_ended),
    })
    msg.attach(MIMEText(html, 'html'))

    server = get_smtp_server(cfg)
    for recipient in cfg['messages']['to'].split(','):
        msg['To'] = cfg['messages']['to']
        send_email_smtp(cfg, server, msg)
    server.quit()

    sys.exit(exit_status)


if __name__ == '__main__':
    main()
