#!/usr/bin/python

# Copyright (C) 2007 - 2009 Canonical Ltd.
# Author: Martin Pitt <martin.pitt@ubuntu.com>
# 
# This program is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation; either version 2 of the License, or (at your
# option) any later version.  See http://www.gnu.org/copyleft/gpl.html for
# the full text of the license.

import os, time, optparse, subprocess, sys, signal, zlib, errno, stat, shutil

from apport.crashdb import get_crashdb

# TODO: permanently save a set for crashes which aren't for our chroot map

#
# classes
#

class CrashDigger:
    def __init__(self, chroot_map, auth_file, verbose=False,
        dup_db=None, dupcheck_mode=False):
        '''Initialize pools.'''

        self.retrace_pool = set()
        self.dupcheck_pool = set()
        self.verbose = verbose
        self.chroot_map = chroot_map
        self.auth_file = auth_file
        self.dup_db = dup_db
        self.dupcheck_mode = dupcheck_mode

        self.log('Initializing crash digger, using chroot map %s' % self.chroot_map)

	# read chroot map, verify it, and get available releases
        if self.chroot_map:
            now = time.time()
            outdated_chroots = False
            m = eval(open(self.chroot_map).read(), {}, {})
            for r, chroot in m.iteritems():
                if not os.path.exists(chroot):
                    print >> sys.stderr, 'Error: chroot %s for %s does not exist' % \
                        (chroot, r)
                    sys.exit(1)
                st = os.stat(chroot)
                if now - st.st_mtime > 86400 and not stat.S_ISDIR(st.st_mode):
                    outdated_chroots = True
            self.releases = m.keys()
            self.log('Available releases: %s' % str(self.releases))

            # upgrade tarball chroots if necessary
            if outdated_chroots:
                assert subprocess.call(['apport-chroot', '-vm', self.chroot_map,
                    'upgrade', 'all'], stdout=sys.stdout, 
                    stderr=subprocess.STDOUT) == 0

	self.crashdb = get_crashdb(auth_file)

        if self.dup_db:
            self.crashdb.init_duplicate_db(self.dup_db)
            # this verified DB integrity; make a backup now
            shutil.copy2(self.dup_db, self.dup_db + '.backup')

    def log(self, str):
        '''If verbosity is enabled, log the given string to stdout, and prepend
        the current date and time.'''

        if self.verbose:
            print >> sys.stdout, '%s: %s' % (time.strftime('%x %X'), str)
            sys.stdout.flush()

    def fill_pool(self):
        '''Query crash db for new IDs to process.
        
        This function also takes care of regularly consolidating the duplicate
        database.'''

        if self.dup_db and self.dupcheck_mode:
            if self.crashdb.duplicate_db_needs_consolidation():
                self.log('Consolidating duplicate database...')
                self.crashdb.duplicate_db_consolidate()

        if self.dupcheck_mode:
            self.dupcheck_pool.update(self.crashdb.get_dup_unchecked())
            self.log('fill_pool: dup check pool now: %s' % str(self.dupcheck_pool))
        else:
            self.retrace_pool.update(self.crashdb.get_unretraced())
            self.log('fill_pool: retrace pool now: %s' % str(self.retrace_pool))

    def retrace_next(self):
        '''Grab an ID from the retrace pool and retrace it.'''

        id = self.retrace_pool.pop()
        self.log('retracing #%i' % id)

        try:
            rel = self.crashdb.get_distro_release(id)
        except ValueError:
	    self.log('could not determine release -- no DistroRelease field?')
            self.crashdb.mark_retraced(id)
	    return
	if rel not in self.releases:
	    self.log('crash is release %s which does not have a chroot available, skipping' % rel)
            # TODO: self.no_chroot_pool.add(id)
	    return

        argv = ['apport-chroot', '-m', self.chroot_map, '--auth',
            self.auth_file]
        if self.dup_db:
            argv += ['--duplicate-db', self.dup_db]
        argv += ['retrace', str(id)]

        result = subprocess.call(argv, stdout=sys.stdout,
            stderr=subprocess.STDOUT)
        if result != 0:
            self.log('retracing #%i failed with status: %i' % (id, result))
            if result == 99:
                self.retrace_pool = set()
                self.log('transient error reported; halting')
                return
            raise SystemError, 'retracing #%i failed' % id

        self.crashdb.mark_retraced(id)

    def dupcheck_next(self):
        '''Grab an ID from the dupcheck pool and process it.'''

        id = self.dupcheck_pool.pop()
        self.log('checking #%i for duplicate' % id)

        try:
            report = self.crashdb.download(id)
        except (MemoryError, TypeError, ValueError, IOError, zlib.error), e:
            self.log('Cannot download report: ' + str(e))
            print >> sys.stderr, 'Cannot download report %i:' % id, str(e)
            return

        res = self.crashdb.check_duplicate(id, report)
        if res:
            if res[1] == None:
                self.log('Report is a duplicate of #%i (not fixed yet)' % res[0])
            elif res[1] == '':
                self.log('Report is a duplicate of #%i (fixed in latest version)' % res[0])
            else:
                self.log('Report is a duplicate of #%i (fixed in version %s)' % res)
        else:
            self.log('Duplicate check negative')

    def run(self):
        '''Process the work pools until they are empty.'''

        self.fill_pool()
        while self.dupcheck_pool:
            self.dupcheck_next()
        while self.retrace_pool:
            self.retrace_next()

#
# functions
#

def parse_options():
    '''Parse command line options and return (options, args) tuple.'''

    optparser = optparse.OptionParser('%prog [options]')
    optparser.add_option('-m', '--chroot-map',
        help='Path to chroot map. This is a file that defines a Python dictionary, mapping DistroRelease: values to chroot paths',
        action='store', type='string', dest='chroot_map', metavar='FILE', default=None)
    optparser.add_option('-a', '--auth',
        help='Path to a file with the crash database authentication information.',
        action='store', type='string', dest='auth_file', default=None)
    optparser.add_option('-l', '--lock',
        help='Lock file; will be created and removed on successful exit, and '
            'program immediately aborts if it already exists',
        action='store', dest='lockfile', default=None)
    optparser.add_option('-d', '--duplicate-db',
        help='Path to the duplicate sqlite database (default: disabled)',
        action='store', type='string', dest='dup_db', metavar='PATH',
        default=None)
    optparser.add_option('-D', '--dupcheck',
        help='Only check duplicates for architecture independent crashes (like Python exceptions)',
        action='store_true', dest='dupcheck_mode', default=False)
    optparser.add_option('-v', '--verbose',
        help='Verbose operation (also passed to apport-retrace)',
        action='store_true', dest='verbose', default=False)

    (opts, args) = optparser.parse_args()

    if not opts.chroot_map and not opts.dupcheck_mode:
        print >> sys.stderr, 'Error: --chroot-map or --dupcheck needs to be given'
        sys.exit(1)
    if not opts.auth_file:
        print >> sys.stderr, 'Error: -a/--auth needs to be given'
        sys.exit(1)
    
    return (opts, args)

#
# main
#

opts, args = parse_options()

if opts.lockfile:
    try:
        f = os.open(opts.lockfile, os.O_WRONLY|os.O_CREAT|os.O_EXCL, 0666)
        os.close(f)
    except OSError, e:
        if e.errno == errno.EEXIST:
            sys.exit(0)
        else:
            raise

CrashDigger(opts.chroot_map, opts.auth_file, opts.verbose, opts.dup_db,
    opts.dupcheck_mode).run()

if opts.lockfile:
    os.unlink(opts.lockfile)
