#!/usr/bin/python3

# Copyright (C) 2007 - 2011 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, optparse, subprocess, sys, zlib, errno, shutil

import apport
from apport.crashdb import get_crashdb


#
# classes
#

class CrashDigger:
    def __init__(self, config_dir, auth_file, cache_dir, sandbox_dir,
                 apport_retrace, verbose=False, dup_db=None, dupcheck_mode=False,
                 publish_dir=None, crash_db=None):
        '''Initialize pools.'''

        self.retrace_pool = set()
        self.dupcheck_pool = set()
        self.config_dir = config_dir
        self.cache_dir = cache_dir
        self.sandbox_dir = sandbox_dir
        self.verbose = verbose
        self.auth_file = auth_file
        self.dup_db = dup_db
        self.dupcheck_mode = dupcheck_mode
        try:
            self.crashdb = get_crashdb(auth_file, name=crash_db)
        except KeyError:
            apport.error('Crash database %s does not exist', crash_db)
            sys.exit(1)
        self.lp = False
        try:
            if self.crashdb.launchpad:
                self.lp = True
        except AttributeError:
            pass
        self.apport_retrace = apport_retrace
        self.publish_dir = publish_dir
        if config_dir:
            self.releases = os.listdir(config_dir)
            self.releases.sort()
            apport.log('Available releases: %s' % str(self.releases), True)
        else:
            self.releases = None

        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 fill_pool(self):
        '''Query crash db for new IDs to process.'''

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

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

        id = self.retrace_pool.pop()
        apport.log('retracing %s#%i (left in pool: %i)' %
                   ("LP: " if self.lp else "", id, len(self.retrace_pool)), True)

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

        argv = [self.apport_retrace, '-S', self.config_dir, '--auth',
                self.auth_file, '--timestamps']
        if self.cache_dir:
            argv += ['--cache', self.cache_dir]
        if self.sandbox_dir:
            argv += ['--sandbox-dir', self.sandbox_dir]
        if self.dup_db:
            argv += ['--duplicate-db', self.dup_db]
        if self.verbose:
            argv.append('-v')
        argv.append(str(id))

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

        self.crashdb.mark_retraced(id)

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

        id = self.dupcheck_pool.pop()
        apport.log('checking %s#%i for duplicate (left in pool: %i)' %
                   ("LP: " if self.lp else "", id, len(self.dupcheck_pool)), True)

        try:
            report = self.crashdb.download(id)
        except (MemoryError, TypeError, ValueError, IOError, AssertionError, zlib.error) as e:
            if str(e) == "bug description must contain standard apport format data":
                apport.log('Cannot download report: ' + str(e), True)
                apport.error('Cannot download report %i: %s', id, str(e))
                return
            apport.log('Cannot download report: ' + str(e), True)
            apport.error('Cannot download report %i: %s', id, str(e))
            return

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

    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()

        if self.publish_dir:
            self.crashdb.duplicate_db_publish(self.publish_dir)


#
# functions
#

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

    optparser = optparse.OptionParser('%prog [options]')
    optparser.add_option('-c', '--config-dir', metavar='DIR',
                         help='Packaging system configuration base directory.')
    optparser.add_option('--sandbox-dir', metavar='DIR',
                         help='Directory for unpacked packages. Future runs will assume that any already downloaded package is also extracted to this sandbox.')
    optparser.add_option('-C', '--cache', metavar='DIR',
                         help='Cache directory for packages downloaded in the sandbox')
    optparser.add_option('-a', '--auth', dest='auth_file',
                         help='Path to a file with the crash database authentication information.')
    optparser.add_option('-l', '--lock', dest='lockfile',
                         help='Lock file; will be created and removed on successful exit, and '
                              'program immediately aborts if it already exists')
    optparser.add_option('-d', '--duplicate-db', dest='dup_db', metavar='PATH',
                         help='Path to the duplicate sqlite database (default: disabled)')
    optparser.add_option('--crash-db', metavar='NAME',
                         help='Use a different crash database than the "default" in /etc/apport/crashdb.conf')
    optparser.add_option('-D', '--dupcheck', dest='dupcheck_mode', default=False, action='store_true',
                         help='Only check duplicates for architecture independent crashes (like Python exceptions)')
    optparser.add_option('-v', '--verbose', action='store_true', default=False,
                         help='Verbose operation (also passed to apport-retrace)')
    optparser.add_option('--apport-retrace', metavar='PATH',
                         help='Path to apport-retrace script (default: directory of crash-digger or $PATH)')
    optparser.add_option('--publish-db', metavar='DIR',
                         help='After processing all reports, publish duplicate database to given directory')

    (opts, args) = optparser.parse_args()

    if not opts.config_dir and not opts.dupcheck_mode:
        apport.fatal('Error: --config-dir or --dupcheck needs to be given')
    if not opts.auth_file:
        apport.fatal('Error: -a/--auth needs to be given')

    return (opts, args)


#
# main
#

opts, args = parse_options()


# support running from tree, then fall back to $PATH
if not opts.apport_retrace:
    opts.apport_retrace = os.path.join(os.path.dirname(sys.argv[0]), 'apport-retrace')
    if not os.access(opts.apport_retrace, os.X_OK):
        opts.apport_retrace = 'apport-retrace'

if opts.lockfile:
    try:
        f = os.open(opts.lockfile, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666)
        os.write(f, ("%u\n" % os.getpid()).encode())
        os.close(f)
    except OSError as e:
        if e.errno == errno.EEXIST:
            sys.exit(0)
        else:
            raise

try:
    CrashDigger(opts.config_dir, opts.auth_file, opts.cache, opts.sandbox_dir,
                opts.apport_retrace, opts.verbose, opts.dup_db,
                opts.dupcheck_mode, opts.publish_db, opts.crash_db).run()
except SystemExit as exit:
    if exit.code == 99:
        pass  # fall through lock cleanup
    else:
        raise

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