#!/usr/bin/python3
# -*- python -*-

# Copyright 2014..2016, W. Martin Borgert <debacle@debian.org>
# License: AGPL-3+

# Python standard modules
import argparse
import collections
import configparser
import email.mime.text
import email.utils
import hashlib
import os
import smtplib
import socket
import subprocess
import sys
import textwrap

# additional modules
import apt
import prettytable
import sleekxmpp

longname = "Pain in the APT"
shortname = "painintheapt"

columns = ["Name", "Installed", "Candidate"]
Package = collections.namedtuple('Package', " ".join(columns).lower())


def getargs():
    ap = argparse.ArgumentParser(
        description='Pester people about available package updates'
        + ' by email or jabber.',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
    ap.add_argument('-c', '--configfile', default='/etc/%s.conf' % shortname,
                    help='configuration file')
    ap.add_argument('-d', '--debug', default=False, action='store_true',
                    help='print debug output to stderr')
    ap.add_argument('-f', '--force', default=False, action='store_true',
                    help='send message, even if updates did not change')
    ap.add_argument('-s', '--stampfile', help='stamp file',
                    default='/var/lib/%s/stamp' % shortname)
    return ap.parse_args()


def update():
    """Create the APT cache and update it.

    Return the cache and a list of updates.
    """
    updates = []
    cache = apt.Cache()
    cache.update()
    cache.open()
    cache.upgrade(dist_upgrade=True)
    changes = cache.get_changes()
    for c in changes:
        name = c._pkg.name
        pkg = cache[name]
        installed = pkg.installed.version if pkg.installed else "-"
        candidate = pkg.candidate.version if pkg.candidate else "-"
        updates.append(Package(name, installed, candidate))
    return cache, updates


def wrap(text, maxwid):
    """Fill paragraph."""
    return "\n".join(textwrap.wrap(text, maxwid))


def get_changelogs(cache):
    """Download changelogs. Beware: This is very slow.

    Identical changelogs for different binary packages are combined.
    """
    changelogs = collections.defaultdict(list)
    changes = cache.get_changes()
    for c in changes:
        name = c._pkg.name
        changelog = cache[name].get_changelog().strip()
        changelogs[changelog].append(name)
    # now do some very fancy formatting
    maxwid = 79
    return ("\n" + "-" * maxwid + "\n").join(sorted(
        [wrap(', '.join(sorted(names)), maxwid) + ":\n\n" + changelog
         for changelog, names in changelogs.items()]))


def maketable(lst):
    """Create a pretty table of package updates."""
    table = prettytable.PrettyTable(columns)
    table.sortby = columns[0]
    table.align = 'l'
    maxwid = 23
    for element in lst:
        table.add_row([wrap(element.name, maxwid),
                       wrap(element.installed, maxwid),
                       wrap(element.candidate, maxwid)])
    return table.get_string()


class JabberBot(sleekxmpp.ClientXMPP):
    def __init__(self, jid, password, to, room, nick, message):
        sleekxmpp.ClientXMPP.__init__(self, jid, password)
        self.to = to
        self.room = room
        self.nick = nick
        self.add_event_handler("session_start", self.start)
        self.message = message

    def start(self, event):
        self.getRoster()
        self.sendPresence()
        for to in self.to:
            self.send_message(mto=to, mbody=self.message)
        if self.room:
            self.plugin['xep_0045'].joinMUC(self.room, self.nick, wait=True)
            self.send_message(mto=self.room, mbody=self.message,
                              mtype='groupchat')
        try:
            self.disconnect(wait=True)
        except TypeError:      # older SleekXMPP doesn't have "wait"
            import time
            time.sleep(10)
            self.disconnect()


def sendxmpp(config, table, count, host, debug):
    """Send message to a jabber conference room."""
    jid = config.get("jid", "")
    password = config.get("password", "")
    to = config.get("to", "").split(",")
    room = config.get("room")
    headline = '%d package update(s) for %s' % (count, host)
    xmpp = JabberBot(jid, password, to, room, longname,
                     "\n\n".join([headline, table]))
    xmpp.register_plugin('xep_0030')  # service discovery
    if room:
        xmpp.register_plugin('xep_0045')  # multi-user chat
    xmpp.register_plugin('xep_0199')  # XMPP ping

    if xmpp.connect():
        xmpp.process(threaded=False)
    else:
        raise("XMPP connect() failed")


def sendsmtp(config, table, count, host, debug, changelogs):
    """Send email by SMTP to whomsoever it may concern."""
    server = config.get("server", "localhost")
    port = config.getint("port", 25)
    username = config.get("username", "")
    password = config.get("password", "")
    from_ = config.get("from", username)
    to = config.get("to", username)
    cc = config.get("cc", "")

    msg = email.mime.text.MIMEText(
        "\n\n".join([table, changelogs]), 'plain', 'utf-8')
    msg['From'] = from_
    msg['To'] = to
    msg['Subject'] = '%d package update(s) for %s' % (count, host)
    msg['X-Mailer'] = longname

    if cc:
        msg['Cc'] = cc

    s = smtplib.SMTP(host=server, port=port)
    if debug:
        s.set_debuglevel(True)
    s.starttls()
    s.ehlo_or_helo_if_needed()
    if username or password:
        s.login(username, password)
    recipients = [r[1] for r in email.utils.getaddresses([to + "," + cc])]
    s.sendmail(from_, list(set(recipients)), msg.as_string())
    s.quit()


def sendmailx(config, table, count, host, debug, changelogs):
    """Send email by mailx to whomsoever it may concern."""
    cmd = ["/usr/bin/mailx",
           "-r", config.get("from", "root"),
           "-s", '%d package update(s) for %s' % (count, host),
           "-a", "X-Mailer: " + longname]
    cc = config.get("cc", "")
    if cc:
        cmd += ["-c", cc]
    # this is taken from apticron
    if os.path.realpath("/usr/bin/mailx") == "/usr/bin/heirloom-mailx":
        cmd += ["-S", "ttycharset=utf-8"]
    else:
        cmd += ["-a", "MIME-Version: 1.0",
                "-a", "Content-type: text/plain; charset=UTF-8",
                "-a", "Content-transfer-encoding: 8bit"]
    to = config.get("to", "root")
    mailx = subprocess.Popen(cmd + [to], stdin=subprocess.PIPE)
    mailx.stdin.write(table + "\n\n" + changelogs)
    mailx.stdin.close()
    mailx.wait()


def has_changed(configfile, table, stampfile):
    change = False
    hashsum = hashlib.sha1()
    for line in open(configfile):
        hashsum.update(line.encode("utf-8"))
    hashsum.update(table.encode("utf-8"))
    newhash = hashsum.hexdigest()
    try:
        with open(stampfile) as f:
            oldhash = f.readline().strip()
    except Exception as err:
        oldhash = "invalid"
    if oldhash != newhash:
        change = True
    return change, newhash


class AcquireProgress(apt.progress.text.AcquireProgress):
    def __init__(self, debug):
        super(AcquireProgress, self).__init__(
            outfile=sys.stderr if debug else open("/dev/null", "w"))


if __name__ == '__main__':
    args = getargs()
    config = configparser.ConfigParser()
    config.read(args.configfile)
    cache, updates = update()
    count = len(updates)
    fqdn = socket.getfqdn()
    ret = 0
    table = maketable(updates) if count else ""

    change, newhash = has_changed(args.configfile, table, args.stampfile)

    try:
        if "XMPP" in config.sections() and (change or args.force):
            sendxmpp(config["XMPP"], table, count, fqdn, args.debug)
    except Exception as err:
        print(str(err), file=sys.stderr)
        ret = 1

    if "SMTP" in config.sections() and (change or args.force):
        sendsmtp(config["SMTP"], table, count, fqdn, args.debug,
                 get_changelogs(cache))

    if "MAILX" in config.sections() and (change or args.force):
        sendmailx(config["MAILX"], table, count, fqdn, args.debug,
                  get_changelogs(cache))

    if change or args.force:
        with open(args.stampfile, "wb") as f:
            f.write(newhash.encode("utf-8"))

    cache.fetch_archives(progress=AcquireProgress(args.debug))

    sys.exit(ret)
