#!/usr/bin/python

import sys
import os
import time
import getopt
import socket
import urllib
import md5
import signal

import lastfm
import lastfm.client
import lastfm.config
import lastfm.marshaller

DAEMON_NAME = 'lastfmsubmitd'
USAGE = 'usage: %s [--debug] [--no-daemon] [--no-network] [--help]' % \
    DAEMON_NAME

CLIENT_ID = 'lsd'
CLIENT_VERSION = '0.33'

PROTOCOL_VERSION = '1.1'
HANDSHAKE_URL_BASE = 'http://post.audioscrobbler.com/'
SUB_CHUNK_SIZE = 10
DEF_INTERVAL = 1

# This should be something small, because the server times out quickly. If
# there is any congestion at either end of the network, it will give up and
# try to process a partial read, leading to "not all request variables set"
# errors. So, there is little point in waiting around for the rest of the data
# to get there. Of course, we don't want to time out *faster* than the server
# does, because after a timeout we assume none of the subs went through at all
# and will thus retry all of them. If half of the subs were actually read this
# will lead to duplicate submits which will then have to be dropped by the
# spam filter.

HTTP_TIMEOUT = 30

# This, on the other hand, is totally arbitrary.

INITIAL_BACKOFF = 60

class HandshakeError(Exception): pass
class SubTimeoutError(Exception): pass
class SessionError(Exception): pass
class InvalidSubError(Exception): pass
class NoAccountError(Exception): pass

class Throttle:
    """Very simple throttler that ensures we sleep long enough between network
    requests, but lets you sleep a little at a time and then wake to do other
    stuff. Only keeps track of how long it has slept, not the actual time
    elapsed. You can mess with time.slept to simulate a sleep."""

    def __init__(self, cli):
        self.cli = cli
        self.reset()

    def reset(self):
        self.slept = 0
        self.sleep_until = 0

    def ready(self):
        return self.slept >= self.sleep_until

    def backoff(self):
        self.slept = 0
        if self.sleep_until < INITIAL_BACKOFF:
            self.sleep_until = INITIAL_BACKOFF
        elif self.sleep_until < 6 * 3600:
            self.sleep_until *= 2
        self.cli.log.info('Backing off, will retry in %d:%02d' %
            divmod(self.sleep_until, 60))

    def sleep(self, secs):
        time.sleep(secs)
        self.slept += secs

class LsdSpool:
    """Represents uncommitted submissions on disk. May be extended by writing
    a new file in the spool directory."""

    def __init__(self, cli):
        self.cli = cli
        self.files = []
        self.subs = []

    def poll(self):
        new_subs = []
        for f in os.listdir(self.cli.conf.spool_path):
            path = '%s/%s' % (self.cli.conf.spool_path, f)
            if path not in self.files:
                self.files.append(path)
                data = file(path)
                for doc in lastfm.marshaller.load_documents(data):
                    try:
                        for reqd in ('artist', 'title', 'length', 'time'):
                            if not doc.has_key(reqd):
                                raise ValueError('missing %s' % reqd)
                        else:
                            new_subs.append(doc)
                    except ValueError, e:
                        self.cli.log.warning('Invalid data, ignoring: %s' % e)
        if new_subs:
            self.cli.log.debug('Read %d sub(s)' % len(new_subs))
        self.subs += new_subs
        self.subs.sort(key=lambda x: x['time'])

    def sync(self):
        for f in self.files:
            os.unlink(f)
        if self.subs:
            newfile = self.cli.submit_many(self.subs)
            self.files = [newfile]
        else:
            self.files = []

class LastFmSession:
    def __init__(self, cli):
        self.cli = cli
        self.connected = False
        self.interval = DEF_INTERVAL
        self.uncommitted_subs = []
        self.handshake_url = self.make_handshake_url()
        self.submit_url = None

    def make_handshake_url(self):
        args = {
            'hs': 'true',
            'p': PROTOCOL_VERSION,
            'c': CLIENT_ID,
            'v': CLIENT_VERSION,
            'u': self.cli.conf.user,
            }
        return '?'.join([HANDSHAKE_URL_BASE, urllib.urlencode(args)])

    def handshake(self):
        self.cli.log.debug('Handshake URL: %s' % self.handshake_url)

        try:
            signal.alarm(HTTP_TIMEOUT)
            response = urllib.urlopen(self.handshake_url)
            status_line = response.readline().strip()
            signal.alarm(0)
        # XXX: handshaketimeouterror?
        except IOError, e:
            signal.alarm(0)
            raise HandshakeError(e)

        if status_line == 'UPTODATE':
            self.read_submit_url(response)
        elif status_line.startswith('UPDATE'):
            try:
                msg, url = status_line.split(' ', 1)
                self.cli.log.warning('Plugin is out of date: %s' % msg)
                self.cli.log.info('Please go to %s to upgrade' % url)
            except ValueError:
                self.cli.log.warning('Plugin is out of date')
            self.read_submit_url(response)
        elif status_line == 'BADUSER':
            raise HandshakeError('bad username')
        elif status_line.startswith('FAILED'):
            try:
                failed, reason = status_line.split(' ', 1)
            except ValueError:
                reason = 'unknown: "%s"' % status_line
            raise HandshakeError(reason)
        else:
            raise HandshakeError("can't parse response: %s" % status_line)

        self.connected = True
        self.read_interval(response)
        time.sleep(self.interval)

    def read_submit_url(self, response):
        challenge = response.readline().strip()
        self.session_key = self.digest(challenge)
        self.submit_url = response.readline().strip()
        self.cli.log.info('Handshake sucessful')
        self.cli.log.debug('Submit URL: %s' % self.submit_url)

    def read_interval(self, response):
        interval_line = response.readline().strip()
        if interval_line and interval_line.startswith('INTERVAL'):
            msg, secs = interval_line.split(' ', 1)
            interval = int(secs)
            if interval != self.interval:
                self.cli.log.debug('Session interval changed to %d' % interval)
                self.interval = interval

    def digest(self, challenge):
        pass_hash = md5.new(self.cli.conf.password)
        sess_hash = md5.new(pass_hash.hexdigest() + challenge)
        return sess_hash.hexdigest()

    def submit(self, spool):
        while spool.subs:
            chunk = spool.subs[:SUB_CHUNK_SIZE]
            args = {'u': self.cli.conf.user, 's': self.session_key}
            post_data = [urllib.urlencode(args)]
            for i, sub in enumerate(chunk):
                args = {}
                args['a[%d]'%i] = sub['artist'].encode('utf-8')
                args['t[%d]'%i] = sub['title'].encode('utf-8')
                args['l[%d]'%i] = sub['length']
                args['i[%d]'%i] = time.strftime(lastfm.TIME_FMT, sub['time'])
                try:
                    args['b[%d]' % i] = sub['album'].encode('utf-8')
                except KeyError:
                    args['b[%d]' % i] = ''
                try:
                    args['m[%d]' % i] = sub['mbid'].encode('utf-8')
                except KeyError:
                    args['m[%d]' % i] = ''
                self.cli.log.info('Submitting: %s' % lastfm.repr(sub))
                post_data.append(urllib.urlencode(args))

            post_str = '&'.join(post_data)
            self.cli.log.debug('POST data: %s' % post_str)
            try:
                signal.alarm(HTTP_TIMEOUT)
                response = urllib.urlopen(self.submit_url, post_str)
                signal.alarm(0)
            except (IOError, AttributeError), e:
                # The AttributeError is some bizarre urllib bug where
                # http_error_default tries to make an addinfourl with an fp
                # that's actually None. I have no idea. It should be IOError.
                signal.alarm(0)
                raise SubTimeoutError(e)

            failed = False
            status_line = response.readline().strip()

            if status_line == 'OK':
                self.cli.log.info('Submission(s) accepted')
                # Now, and only now, we do our side effect.
                spool.subs = spool.subs[SUB_CHUNK_SIZE:]
                spool.sync()
            elif status_line == 'BADAUTH':
                self.connected = False
                raise SessionError('incorrect password')
            elif status_line.startswith('FAILED'):
                try:
                    failed, reason = status_line.split(' ', 1)
                except ValueError:
                    reason = 'unknown: "%s"' % status_line
                raise InvalidSubError(reason)
            else:
                raise InvalidSubError("can't parse response: %s" %
                    status_line)

            self.read_interval(response)
            time.sleep(self.interval)

class LsdConfig(lastfm.config.Config):
    def __init__(self):
        lastfm.config.Config.__init__(self, search=DAEMON_NAME)
        self.user = self.cp.get('account', 'user', None)
        self.password = self.cp.get('account', 'password', None)
        if not (self.user and self.password):
            raise NoAccountError

def daemon(cli, online):
    session = LastFmSession(cli)
    spool = LsdSpool(cli)
    throttle = Throttle(cli)

    def shutdown(signum, frame):
        spool.sync()
        cli.cleanup()
        sys.exit(0)

    def alarm(signum, frame):
        cli.log.debug('Resuming on alarm')

    def reinit(signum, frame):
        # XXX: reread config, reopen log, reset throttle, rehandshake
        pass

    signal.signal(signal.SIGTERM, shutdown)
    signal.signal(signal.SIGINT, shutdown)
    signal.signal(signal.SIGHUP, reinit)
    signal.signal(signal.SIGALRM, alarm)

    while True:
        if throttle.ready():
            spool.poll()

            if online and not session.connected:
                try:
                    session.handshake()
                    throttle.reset()
                except HandshakeError, e:
                    cli.log.error('Handshake failed: %s' % e)
                    throttle.backoff()

            if session.connected and spool.subs:
                try:
                    cli.log.debug('Doing submit, %d sub(s)' % len(spool.subs))
                    session.submit(spool)
                    cli.log.debug('Done, %d in spool' % len(spool.subs))
                    throttle.reset()
                except SessionError, e:
                    cli.log.error('Session failed: %s' % e)
                    throttle.backoff()
                except InvalidSubError, e:
                    cli.log.error('Submission failed: %s' % e)
                    throttle.backoff()
                except SubTimeoutError:
                    cli.log.error('Submission timed out')
                    throttle.backoff()

        throttle.sleep(cli.conf.sleep_time)

if __name__ == '__main__':
    shortopts = 'dnlh'
    longopts = ['debug', 'no-daemon', 'no-network', 'help']

    try:
        opts, args = getopt.getopt(sys.argv[1:], shortopts, longopts)
    except getopt.GetoptError, e:
        print >>sys.stderr, '%s: %s' % (DAEMON_NAME, e)
        print >>sys.stderr, USAGE
        sys.exit(1)

    debug = False
    fork = True
    stderr = False
    online = True

    for opt, arg in opts:
        if opt in ('--debug', '-d'):
            debug = True
        elif opt in ('--no-daemon', '-n'):
            fork = False
            stderr = True
        elif opt in ('--no-network', '-l'):
            online = False
        elif opt in ('--help', '-h'):
            print USAGE
            sys.exit(0)

    try:
        conf = LsdConfig()
    except NoAccountError:
        print >>sys.stderr, '%s: no account info found; exiting' % \
            DAEMON_NAME
        sys.exit(0)

    if conf.debug:
        debug = True

    if fork:
        try:
            pid = os.fork()
            if pid:
                sys.exit(0)
        except OSError, e:
            print >>sys.stderr, "lastfmsubmitd: can't fork: %s" % e
            sys.exit(1)

    cli = lastfm.client.Daemon(DAEMON_NAME, conf)
    cli.daemonize(fork)
    cli.open_log(debug, stderr)

    try:
        cli.log.info('Starting')
        daemon(cli, online)
    except SystemExit, e:
        cli.log.info('Exiting')
        sys.exit(e.args[0])
    except:
        import traceback
        einfo = traceback.format_exception(*sys.exc_info())
        cli.log.error('Aborting: %s' % ''.join(einfo))
        sys.exit(1)
