# Copyright (C) 2009-2010 Canonical
#
# Authors:
#  Michael Vogt
#
# 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; version 3.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA

import apt_pkg
import dbus
import logging
import os
import re
import traceback

from subprocess import (
    Popen,
    PIPE,
)

from gi.repository import GObject, GLib

from softwarecenter.utils import (
    sources_filename_from_ppa_entry,
    release_filename_in_lists_from_deb_line,
    obfuscate_private_ppa_details,
    utf8,
)
from softwarecenter.enums import (
    TransactionTypes,
    PURCHASE_TRANSACTION_ID,
)
from softwarecenter.paths import APPORT_RECOVERABLE_ERROR

from aptdaemon import client
from aptdaemon import enums
from aptdaemon import errors
from aptsources.sourceslist import SourceEntry
from aptdaemon import policykit1

from defer import inline_callbacks, return_value

from softwarecenter.db.application import Application
from softwarecenter.backend.transactionswatcher import (
    BaseTransactionsWatcher,
    BaseTransaction,
    TransactionFinishedResult,
    TransactionProgress,
)
from softwarecenter.backend.installbackend import InstallBackend

from gettext import gettext as _

# its important that we only have a single dbus BusConnection
# per address when using the fake dbus aptd
buses = {}


def get_dbus_bus():
    if "SOFTWARE_CENTER_APTD_FAKE" in os.environ:
        global buses
        dbus_address = os.environ["SOFTWARE_CENTER_APTD_FAKE"]
        if dbus_address in buses:
            return buses[dbus_address]
        bus = buses[dbus_address] = dbus.bus.BusConnection(dbus_address)
    else:
        bus = dbus.SystemBus()
    return bus


class FakePurchaseTransaction(object):

    def __init__(self, app, iconname):
        self.pkgname = app.pkgname
        self.appname = app.appname
        self.iconname = iconname
        self.progress = 0
        self.tid = PURCHASE_TRANSACTION_ID


class AptdaemonTransaction(BaseTransaction):

    def __init__(self, trans):
        self._trans = trans

    @property
    def tid(self):
        return self._trans.tid

    @property
    def status_details(self):
        return self._trans.status_details

    @property
    def meta_data(self):
        return self._trans.meta_data

    @property
    def cancellable(self):
        return self._trans.cancellable

    @property
    def progress(self):
        return self._trans.progress

    def get_role_description(self, role=None):
        role = self._trans.role if role is None else role
        return enums.get_role_localised_present_from_enum(role)

    def get_status_description(self, status=None):
        status = self._trans.status if status is None else status
        return enums.get_status_string_from_enum(status)

    def is_waiting(self):
        return self._trans.status == enums.STATUS_WAITING_LOCK

    def is_downloading(self):
        return self._trans.status == enums.STATUS_DOWNLOADING

    def cancel(self):
        return self._trans.cancel()

    def connect(self, signal, handler, *args):
        """ append the real handler to the arguments """
        args = args + (handler, )
        return self._trans.connect(signal, self._handler, *args)

    def _handler(self, trans, *args):
        """ translate trans to BaseTransaction type.
        call the real handler after that
        """
        real_handler = args[-1]
        args = tuple(args[:-1])
        if isinstance(trans, client.AptTransaction):
            trans = AptdaemonTransaction(trans)
        return real_handler(trans, *args)


class AptdaemonTransactionsWatcher(BaseTransactionsWatcher):
    """
    base class for objects that need to watch the aptdaemon
    for transaction changes. it registers a handler for the daemon
    going away and reconnects when it appears again
    """

    def __init__(self):
        super(AptdaemonTransactionsWatcher, self).__init__()
        # watch the daemon exit and (re)register the signal
        bus = get_dbus_bus()
        self._owner_watcher = bus.watch_name_owner(
            "org.debian.apt", self._register_active_transactions_watch)

    def _register_active_transactions_watch(self, connection):
        #print "_register_active_transactions_watch", connection
        bus = get_dbus_bus()
        apt_daemon = client.get_aptdaemon(bus=bus)
        apt_daemon.connect_to_signal("ActiveTransactionsChanged",
                                     self._on_transactions_changed)
        current, queued = apt_daemon.GetActiveTransactions()
        self._on_transactions_changed(current, queued)

    def _on_transactions_changed(self, current, queued):
        self.emit("lowlevel-transactions-changed", current, queued)

    def get_transaction(self, tid):
        """ synchronously return a transaction """
        try:
            trans = client.get_transaction(tid)
            return AptdaemonTransaction(trans)
        except dbus.DBusException:
            pass


class AptdaemonBackend(GObject.GObject, InstallBackend):
    """ software center specific code that interacts with aptdaemon """

    __gsignals__ = {'transaction-started': (GObject.SIGNAL_RUN_FIRST,
                                            GObject.TYPE_NONE,
                                            (str, str, str, str)),
                    # emits a TransactionFinished object
                    'transaction-finished': (GObject.SIGNAL_RUN_FIRST,
                                             GObject.TYPE_NONE,
                                             (GObject.TYPE_PYOBJECT, )),
                    # emits a TransactionFinished object
                    'transaction-stopped': (GObject.SIGNAL_RUN_FIRST,
                                            GObject.TYPE_NONE,
                                            (GObject.TYPE_PYOBJECT,)),
                    # emits a TransactionFinished object
                    'transaction-cancelled': (GObject.SIGNAL_RUN_FIRST,
                                              GObject.TYPE_NONE,
                                              (GObject.TYPE_PYOBJECT,)),
                    # emits with a pending_transactions list object
                    'transactions-changed': (GObject.SIGNAL_RUN_FIRST,
                                             GObject.TYPE_NONE,
                                             (GObject.TYPE_PYOBJECT, )),
                    # emits pkgname, percent
                    'transaction-progress-changed': (GObject.SIGNAL_RUN_FIRST,
                                                     GObject.TYPE_NONE,
                                                     (str, int,)),
                    # the number/names of the available channels changed
                    'channels-changed': (GObject.SIGNAL_RUN_FIRST,
                                         GObject.TYPE_NONE,
                                         (bool,)),
                    # cache reload emits this specific signal as well
                    'reload-finished': (GObject.SIGNAL_RUN_FIRST,
                                        GObject.TYPE_NONE,
                                        (GObject.TYPE_PYOBJECT, bool,)),
                    }

    LICENSE_KEY_SERVER = "ubuntu-production"

    def __init__(self):
        GObject.GObject.__init__(self)
        InstallBackend.__init__(self)

        bus = get_dbus_bus()
        self.aptd_client = client.AptClient(bus=bus)
        self.pending_transactions = {}
        self._transactions_watcher = AptdaemonTransactionsWatcher()
        self._transactions_watcher.connect("lowlevel-transactions-changed",
            self._on_lowlevel_transactions_changed)
        # dict of pkgname -> FakePurchaseTransaction
        self.pending_purchases = {}
        self._progress_signal = None
        self._logger = logging.getLogger("softwarecenter.backend")
        # the AptdaemonBackendUI code
        self.ui = None

    def _axi_finished(self, res):
        self.emit("channels-changed", res)

    # public methods
    def update_xapian_index(self):
        self._logger.debug("update_xapian_index")
        system_bus = get_dbus_bus()
        # axi is optional, so just do nothing if its not installed
        try:
            axi = dbus.Interface(
                system_bus.get_object("org.debian.AptXapianIndex", "/"),
                "org.debian.AptXapianIndex")
        except dbus.DBusException as e:
            self._logger.warning("axi can not be updated '%s'" % e)
            return
        axi.connect_to_signal("UpdateFinished", self._axi_finished)
        # we don't really care for updates at this point
        #axi.connect_to_signal("UpdateProgress", progress)
        try:
            # first arg is force, second update_only
            axi.update_async(True, False)
        except:
            self._logger.warning("could not update axi")

    @inline_callbacks
    def fix_broken_depends(self):
        trans = None
        try:
            trans = yield self.aptd_client.fix_broken_depends(defer=True)
            self.emit("transaction-started", "", "", trans.tid,
                TransactionTypes.REPAIR)
            yield self._run_transaction(trans, None, None, None)
        except Exception as error:
            self._on_trans_error(error, trans)

    @inline_callbacks
    def fix_incomplete_install(self):
        trans = None
        try:
            trans = yield self.aptd_client.fix_incomplete_install(defer=True)
            self.emit("transaction-started", "", "", trans.tid,
                      TransactionTypes.REPAIR)
            yield self._run_transaction(trans, None, None, None)
        except Exception as error:
            self._on_trans_error(error, trans)

    # FIXME: upgrade add-ons here
    @inline_callbacks
    def upgrade(self, app, iconname, addons_install=[], addons_remove=[],
                metadata=None):
        """ upgrade a single package """
        pkgname = app.pkgname
        appname = app.appname
        trans = None
        try:
            trans = yield self.aptd_client.upgrade_packages([pkgname],
                                                            defer=True)
            self.emit("transaction-started", pkgname, appname, trans.tid,
                TransactionTypes.UPGRADE)
            yield self._run_transaction(trans, pkgname, appname, iconname,
                metadata)
        except Exception as error:
            self._on_trans_error(error, trans, pkgname)

# broken
#    @inline_callbacks
#    def _simulate_remove_multiple(self, pkgnames):
#        try:
#            trans = yield self.aptd_client.remove_packages(pkgnames,
#                                                           defer=True)
#            trans.connect("dependencies-changed",
#                self._on_dependencies_changed)
#        except Exception:
#            logging.exception("simulate_remove")
#        return_value(trans)
#
#   def _on_dependencies_changed(self, *args):
#        print "_on_dependencies_changed", args
#        self.have_dependencies = True
#
#    @inline_callbacks
#    def simulate_remove_multiple(self, pkgnames):
#        self.have_dependencies = False
#        trans = yield self._simulate_remove_multiple(pkgnames)
#        print trans
#        while not self.have_dependencies:
#            while gtk.events_pending():
#                gtk.main_iteration()
#            time.sleep(0.01)

    @inline_callbacks
    def remove(self, app, iconname, addons_install=[], addons_remove=[],
               metadata=None):
        """ remove a single package """
        pkgname = app.pkgname
        appname = app.appname
        trans = None
        try:
            trans = yield self.aptd_client.remove_packages([pkgname],
                                                           defer=True)
            self.emit("transaction-started", pkgname, appname, trans.tid,
                TransactionTypes.REMOVE)
            yield self._run_transaction(trans, pkgname, appname, iconname,
                metadata)
        except Exception as error:
            self._on_trans_error(error, trans, pkgname)

    @inline_callbacks
    def remove_multiple(self, apps, iconnames, addons_install=[],
                        addons_remove=[], metadatas=None):
        """ queue a list of packages for removal  """
        if metadatas is None:
            metadatas = []
            for item in apps:
                metadatas.append(None)
        for app, iconname, metadata in zip(apps, iconnames, metadatas):
            yield self.remove(app, iconname, metadata)

    @inline_callbacks
    def install(self, app, iconname, filename=None, addons_install=[],
                addons_remove=[], metadata=None, force=False):
        """Install a single package from the archive
           If filename is given a local deb package is installed instead.
        """
        trans = None
        pkgname = app.pkgname
        appname = app.appname
        # this will force aptdaemon to use the right archive suite on install
        if app.archive_suite:
            pkgname = "%s/%s" % (pkgname, app.archive_suite)
        try:
            if filename:
                # force means on lintian failure
                trans = yield self.aptd_client.install_file(
                    filename, force=force, defer=True)
                self.emit("transaction-started", pkgname, appname, trans.tid,
                    TransactionTypes.INSTALL)
                yield trans.set_meta_data(sc_filename=filename, defer=True)
            else:
                install = [pkgname] + addons_install
                remove = addons_remove
                reinstall = remove = purge = upgrade = downgrade = []
                trans = yield self.aptd_client.commit_packages(
                    install, reinstall, remove, purge, upgrade, downgrade,
                    defer=True)
                self.emit("transaction-started", pkgname, appname, trans.tid,
                    TransactionTypes.INSTALL)
            yield self._run_transaction(
                trans, pkgname, appname, iconname, metadata)
        except Exception as error:
            self._on_trans_error(error, trans, pkgname)

    @inline_callbacks
    def install_multiple(self, apps, iconnames, addons_install=[],
                         addons_remove=[], metadatas=None):
        """ queue a list of packages for install  """
        if metadatas is None:
            metadatas = []
            for item in apps:
                metadatas.append(None)
        for app, iconname, metadata in zip(apps, iconnames, metadatas):
            yield self.install(app, iconname, metadata=metadata)

    @inline_callbacks
    def apply_changes(self, app, iconname, addons_install=[],
                      addons_remove=[], metadata=None):
        """ install and remove add-ons """
        pkgname = app.pkgname
        appname = app.appname
        trans = None
        try:
            install = addons_install
            remove = addons_remove
            reinstall = remove = purge = upgrade = downgrade = []
            trans = yield self.aptd_client.commit_packages(
                install, reinstall, remove, purge, upgrade, downgrade,
                defer=True)
            self.emit("transaction-started", pkgname, appname, trans.tid,
                TransactionTypes.APPLY)
            yield self._run_transaction(trans, pkgname, appname, iconname)
        except Exception as error:
            self._on_trans_error(error, trans)

    @inline_callbacks
    def reload(self, sources_list=None, metadata=None):
        """ reload package list """
        # check if the sourcespart is there, if not, do a full reload
        # this can happen when the "partner" repository is added, it
        # will be in the main sources.list already and this means that
        # aptsources will just enable it instead of adding a extra
        # sources.list.d file (LP: #666956)
        d = apt_pkg.config.find_dir("Dir::Etc::sourceparts")
        trans = None
        if (not sources_list or
                not os.path.exists(os.path.join(d, sources_list))):
            sources_list = ""
        try:
            trans = yield self.aptd_client.update_cache(
                sources_list=sources_list, defer=True)
            yield self._run_transaction(trans, None, None, None, metadata)
        except Exception as error:
            self._on_trans_error(error, trans)
        # note that the cache re-open will happen via the connected
        # "transaction-finished" signal

    @inline_callbacks
    def enable_component(self, component):
        self._logger.debug("enable_component: %s" % component)
        trans = None
        try:
            trans = yield self.aptd_client.enable_distro_component(component)
            # don't use self._run_transaction() here, to avoid sending unneeded
            # signals
            yield trans.run(defer=True)
        except Exception as error:
            self._on_trans_error(error, trans, component)
            return_value(None)
        # now update the cache
        yield self.reload()

    @inline_callbacks
    def enable_channel(self, channelfile):
        trans = None
        # read channel file and add all relevant lines
        for line in open(channelfile):
            line = line.strip()
            if not line:
                continue
            entry = SourceEntry(line)
            if entry.invalid:
                continue
            sourcepart = os.path.basename(channelfile)
            yield self.add_sources_list_entry(entry, sourcepart)
            keyfile = channelfile.replace(".list", ".key")
            if os.path.exists(keyfile):
                trans = yield self.aptd_client.add_vendor_key_from_file(
                    keyfile, wait=True)
                # don't use self._run_transaction() here, to avoid sending
                # unneeded signals
                yield trans.run(defer=True)
        yield self.reload(sourcepart)

    @inline_callbacks
    def add_vendor_key_from_keyserver(self, keyid,
                                  keyserver="hkp://keyserver.ubuntu.com:80/",
                                  metadata=None):
        # strip the keysize
        if "/" in keyid:
            keyid = keyid.split("/")[1]
        if not keyid.startswith("0x"):
            keyid = "0x%s" % keyid
        trans = None
        try:
            trans = yield self.aptd_client.add_vendor_key_from_keyserver(
                keyid, keyserver, defer=True)
            yield self._run_transaction(trans, None, None, None, metadata)
        except Exception as error:
            self._on_trans_error(error, trans)

    @inline_callbacks
    def add_sources_list_entry(self, source_entry, sourcepart=None):
        if isinstance(source_entry, basestring):
            entry = SourceEntry(source_entry)
        elif isinstance(source_entry, SourceEntry):
            entry = source_entry
        else:
            raise ValueError("Unsupported entry type %s" % type(source_entry))

        if not sourcepart:
            sourcepart = sources_filename_from_ppa_entry(entry)

        args = (entry.type, entry.uri, entry.dist, entry.comps,
                "Added by software-center", sourcepart)
        trans = None
        try:
            trans = yield self.aptd_client.add_repository(*args, defer=True)
            yield self._run_transaction(trans, None, None, None)
        except errors.NotAuthorizedError, err:
            self._logger.error("add_repository: '%s'" % err)
            return_value(None)
        return_value(sourcepart)

    @inline_callbacks
    def authenticate_for_purchase(self):
        """
        helper that authenticates with aptdaemon for a purchase operation
        """
        bus = get_dbus_bus()
        name = bus.get_unique_name()
        action = policykit1.PK_ACTION_INSTALL_PURCHASED_PACKAGES
        flags = policykit1.CHECK_AUTH_ALLOW_USER_INTERACTION
        yield policykit1.check_authorization_by_name(name, action, flags=flags)

    @inline_callbacks
    def add_license_key(self, license_key, license_key_path,
                        license_key_oauth, pkgname):
        """ add a license key for a purchase. """
        self._logger.debug(
            "adding license_key for pkg '%s' of len: %i" % (
                pkgname, len(license_key)))
        trans = None
        # HOME based license keys
        if license_key_path and license_key_path.startswith("~"):
            # check if its inside HOME and if so, just create it
            dest = os.path.expanduser(os.path.normpath(license_key_path))
            dirname = os.path.dirname(dest)
            if not os.path.exists(dirname):
                os.makedirs(dirname)
            if not os.path.exists(dest):
                f = open(dest, "w")
                f.write(license_key)
                f.close()
                os.chmod(dest, 0600)
            else:
                self._logger.warn("license file '%s' already exists" % dest)
        else:
            # system-wide keys
            try:
                self._logger.info("adding license key for '%s'" % pkgname)
                trans = yield self.aptd_client.add_license_key(
                    pkgname, license_key_oauth, self.LICENSE_KEY_SERVER)
                yield self._run_transaction(trans, None, None, None)
            except Exception as e:
                self._logger.error("add_license_key: '%s'" % e)

    @inline_callbacks
    def add_repo_add_key_and_install_app(self,
                                         deb_line,
                                         signing_key_id,
                                         app,
                                         iconname,
                                         license_key,
                                         license_key_path,
                                         json_oauth_token=None,
                                         purchase=True):
        """
        a convenience method that combines all of the steps needed
        to install a for-pay application, including adding the
        source entry and the vendor key, reloading the package list,
        and finally installing the specified application once the
        package list reload has completed.
        """
        self._logger.info("add_repo_add_key_and_install_app() '%s' '%s' '%s'" %
            (re.sub("deb https://.*@", "", deb_line),  # strip out password
            signing_key_id,
            app.pkgname))

        if purchase:
            # pre-authenticate
            try:
                yield self.authenticate_for_purchase()
            except:
                self._logger.exception("authenticate_for_purchase failed")
                self._clean_pending_purchases(app.pkgname)
                result = TransactionFinishedResult(None, False)
                result.pkgname = app.pkgname
                self.emit("transaction-stopped", result)
                return
            # done
            fake_trans = FakePurchaseTransaction(app, iconname)
            self.emit("transaction-started", app.pkgname, app.appname,
                fake_trans.tid, TransactionTypes.INSTALL)
            self.pending_purchases[app.pkgname] = fake_trans
        else:
            # FIXME: add authenticate_for_added_repo here
            pass

        # add the metadata early, add_sources_list_entry is a transaction
        # too
        trans_metadata = {
            'sc_add_repo_and_install_appname': app.appname,
            'sc_add_repo_and_install_pkgname': app.pkgname,
            'sc_add_repo_and_install_deb_line': deb_line,
            'sc_iconname': iconname,
            'sc_add_repo_and_install_try': "1",
            'sc_add_repo_and_install_license_key': license_key or "",
            'sc_add_repo_and_install_license_key_path': license_key_path or "",
            'sc_add_repo_and_install_license_key_token':
                json_oauth_token or "",
        }

        self._logger.info("add_sources_list_entry()")
        sourcepart = yield self.add_sources_list_entry(deb_line)
        trans_metadata['sc_add_repo_and_install_sources_list'] = sourcepart

        # metadata so that we know those the add-key and reload transactions
        # are part of a group
        self._logger.info("add_vendor_key_from_keyserver()")
        yield self.add_vendor_key_from_keyserver(signing_key_id,
                                                 metadata=trans_metadata)
        self._logger.info("reload_for_commercial_repo()")
        yield self._reload_for_commercial_repo(app, trans_metadata, sourcepart)

    @inline_callbacks
    def _reload_for_commercial_repo_defer(self, app, trans_metadata,
                                          sources_list):
        """
        helper that reloads and registers a callback for when the reload is
        finished
        """
        trans_metadata["sc_add_repo_and_install_ignore_errors"] = "1"
        # and then queue the install only when the reload finished
        # otherwise the daemon will fail because he does not know
        # the new package name yet
        self.connect("reload-finished",
                     self._on_reload_for_add_repo_and_install_app_finished,
                     trans_metadata, app)
        # reload to ensure we have the new package data
        yield self.reload(sources_list=sources_list, metadata=trans_metadata)

    def _reload_for_commercial_repo(self, app, trans_metadata, sources_list):
        """ this reloads a commercial repo in a glib timeout
            See _reload_for_commercial_repo_inline() for the actual work
            that is done
        """
        self._logger.info("_reload_for_commercial_repo() %s" % app)
        # trigger inline_callbacked function
        self._reload_for_commercial_repo_defer(
            app, trans_metadata, sources_list)
        # return False to stop the timeout (one shot only)
        return False

    @inline_callbacks
    def _on_reload_for_add_repo_and_install_app_finished(self, backend, trans,
                                                         result, metadata,
                                                         app):
        """
        callback that is called once after reload was queued
        and will trigger the install of the for-pay package itself
        (after that it will automatically de-register)
        """
        #print "_on_reload_for_add_repo_and_install_app_finished", trans, \
        #    result, backend, self._reload_signal_id
        self._logger.info("_on_reload_for_add_repo_and_install_app_finished() "
            "%s %s %s" % (trans, result, app))

        # check if this is the transaction we waiting for
        key = "sc_add_repo_and_install_pkgname"
        if not (key in trans.meta_data and
                trans.meta_data[key] == app.pkgname):
            return_value(None)

        # get the debline and check if we have a release.gpg file
        deb_line = trans.meta_data["sc_add_repo_and_install_deb_line"]
        license_key = trans.meta_data["sc_add_repo_and_install_license_key"]
        license_key_path = trans.meta_data[
            "sc_add_repo_and_install_license_key_path"]
        license_key_oauth = trans.meta_data[
            "sc_add_repo_and_install_license_key_token"]
        release_filename = release_filename_in_lists_from_deb_line(deb_line)
        lists_dir = apt_pkg.config.find_dir("Dir::State::lists")
        release_signature = os.path.join(lists_dir, release_filename) + ".gpg"
        self._logger.info("looking for '%s'" % release_signature)
        # no Release.gpg in the newly added repository, try again,
        # this can happen e.g. on odd network proxies
        if not os.path.exists(release_signature):
            self._logger.warn("no %s found, re-trying" % release_signature)
            result = False

        # disconnect again, this is only a one-time operation
        self.disconnect_by_func(
            self._on_reload_for_add_repo_and_install_app_finished)

        # FIXME: this logic will *fail* if the sources.list of the user
        #        was broken before

        # run install action if the repo was added successfully
        if result:
            self.emit("channels-changed", True)

            # we use aptd_client.install_packages() here instead
            # of just
            #  self.install(app, "", metadata=metadata)
            # go get less authentication prompts (because of the
            # 03_auth_me_less patch in aptdaemon)
            try:
                self._logger.info("install_package()")
                trans = yield self.aptd_client.install_packages(
                    [app.pkgname], defer=True)
                self._logger.info("run_transaction()")
                # notify about the install so that the unity-launcher
                # integration works
                self.emit("transaction-started",
                          app.pkgname, app.appname, trans.tid,
                          TransactionTypes.INSTALL)
                yield self._run_transaction(trans, app.pkgname, app.appname,
                                            "", metadata)
            except Exception as error:
                self._on_trans_error(error, trans, app.pkgname)
            # add license_key
            # FIXME: aptd fails if there is a license_key_path already
            #        but I wonder if we should ease that restriction
            if license_key and not os.path.exists(license_key_path):
                yield self.add_license_key(
                    license_key, license_key_path, license_key_oauth,
                    app.pkgname)

        else:
            # download failure
            # ok, here is the fun! we can not reload() immediately, because
            # there is a delay of up to 5(!) minutes between s-c-agent telling
            # us that we can download software and actually being able to
            # download it
            retry = int(trans.meta_data['sc_add_repo_and_install_try'])
            if retry > 10:
                self._logger.error("failed to add repo after 10 tries")
                self._clean_pending_purchases(
                    trans.meta_data['sc_add_repo_and_install_pkgname'])
                self._show_transaction_failed_dialog(trans, result)
                return_value(False)
            # this just sets the meta_data locally, but that is ok, the
            # whole re-try machinery will not survive anyway if the local
            # s-c instance is closed
            self._logger.info("queuing reload in 30s")
            trans.meta_data["sc_add_repo_and_install_try"] = str(retry + 1)
            sourcepart = trans.meta_data[
                "sc_add_repo_and_install_sources_list"]
            GLib.timeout_add_seconds(30, self._reload_for_commercial_repo,
                                     app, trans.meta_data, sourcepart)

    # internal helpers
    def _on_lowlevel_transactions_changed(self, watcher, current, pending):
        # cleanup progress signal (to be sure to not leave dbus
        # matchers around)
        if self._progress_signal:
            GLib.source_remove(self._progress_signal)
            self._progress_signal = None
        # attach progress-changed signal for current transaction
        if current:
            try:
                trans = client.get_transaction(current)
                self._progress_signal = trans.connect("progress-changed",
                    self._on_progress_changed)
            except dbus.DBusException:
                pass

        # now update pending transactions
        self.pending_transactions.clear()
        for tid in [current] + pending:
            if not tid:
                continue
            try:
                trans = client.get_transaction(tid,
                    error_handler=lambda x: True)
            except dbus.DBusException:
                continue
            trans_progress = TransactionProgress(trans)
            try:
                self.pending_transactions[trans_progress.pkgname] = \
                    trans_progress
            except KeyError:
                # if its not a transaction from us (sc_pkgname) still
                # add it with the tid as key to get accurate results
                # (the key of pending_transactions is never directly
                #  exposed in the UI)
                self.pending_transactions[trans.tid] = trans_progress
        # emit signal
        self.inject_fake_transactions_and_emit_changed_signal()

    def inject_fake_transactions_and_emit_changed_signal(self):
        """
        ensures that the fake transactions are considered and emits
        transactions-changed signal with the right pending transactions
        """
        # inject a bunch of FakePurchaseTransaction into the transactions dict
        for pkgname in self.pending_purchases:
            self.pending_transactions[pkgname] = \
                self.pending_purchases[pkgname]
        # and emit the signal
        self.emit("transactions-changed", self.pending_transactions)

    def _on_progress_changed(self, trans, progress):
        """
        internal helper that gets called on our package transaction progress
        (only showing pkg progress currently)
        """
        try:
            pkgname = trans.meta_data["sc_pkgname"]
            self.pending_transactions[pkgname].progress = progress
            self.emit("transaction-progress-changed", pkgname, progress)
        except KeyError:
            pass

    def _show_transaction_failed_dialog(self, trans, enum,
                                        alternative_action=None):
        # daemon died are messages that result from broken
        # cancel handling in aptdaemon (LP: #440941)
        # FIXME: this is not a proper fix, just a workaround
        if trans.error_code == enums.ERROR_DAEMON_DIED:
            self._logger.warn("daemon dies, ignoring: %s %s" % (trans, enum))
            return
        # hide any private ppa details in the error message since it may
        # appear in the logs for LP bugs and potentially in screenshots as well
        cleaned_error_details = obfuscate_private_ppa_details(
            trans.error_details)
        msg = utf8("%s: %s\n%s\n\n%s") % (
            utf8(_("Error")),
            utf8(enums.get_error_string_from_enum(trans.error_code)),
            utf8(enums.get_error_description_from_enum(trans.error_code)),
            utf8(cleaned_error_details))
        self._logger.error("error in _on_trans_finished '%s'" % msg)
        # show dialog to the user and exit (no need to reopen the cache)
        if not trans.error_code:
            # sometimes aptdaemon doesn't return a value for error_code
            # when the network connection has become unavailable; in
            # that case, we will assume it's a failure during a package
            # download because that is the only case where we see this
            # happening - this avoids display of an empty error dialog
            # and correctly prompts the user to check their network
            # connection (see LP: #747172)
            # FIXME: fix aptdaemon to return a valid error_code under
            # all conditions
            trans.error_code = enums.ERROR_PACKAGE_DOWNLOAD_FAILED
        # show dialog to the user and exit (no need to reopen
        # the cache)
        res = self.ui.error(None,
            utf8(enums.get_error_string_from_enum(trans.error_code)),
            utf8(enums.get_error_description_from_enum(trans.error_code)),
            utf8(cleaned_error_details),
            utf8(alternative_action))
        return res

    def _get_app_and_icon_and_deb_from_trans(self, trans):
        meta_copy = trans.meta_data.copy()
        app = Application(meta_copy.pop("sc_appname", None),
                          meta_copy.pop("sc_pkgname"))
        iconname = meta_copy.pop("sc_iconname", None)
        filename = meta_copy.pop("sc_filename", "")
        return app, iconname, filename, meta_copy

    def _on_trans_finished(self, trans, enum):
        """callback when a aptdaemon transaction finished"""
        self._logger.debug("_on_transaction_finished: %s %s %s" % (
                trans, enum, trans.meta_data))

        # first check if there has been a cancellation of
        # the install and fire a transaction-cancelled signal
        # (see LP: #1027209)
        if enum == enums.EXIT_CANCELLED:
            result = TransactionFinishedResult(trans, False)
            self.emit("transaction-cancelled", result)
            return

        # show error
        if enum == enums.EXIT_FAILED:
            # Handle invalid packages separately
            if (trans.error and
                    trans.error.code == enums.ERROR_INVALID_PACKAGE_FILE):
                action = _("_Ignore and install")
                res = self._show_transaction_failed_dialog(
                    trans, enum, action)
                if res == "yes":
                    # Reinject the transaction
                    app, iconname, filename, meta_copy = \
                        self._get_app_and_icon_and_deb_from_trans(trans)
                    self.install(app, iconname, filename, [], [],
                                 metadata=meta_copy, force=True)
                    return
            # on unauthenticated errors, try a "repair" using the
            # reload functionality
            elif (trans.error and
                  trans.error.code == enums.ERROR_PACKAGE_UNAUTHENTICATED):
                action = _("Repair")
                res = self._show_transaction_failed_dialog(
                    trans, enum, action)
                if res == "yes":
                    app, iconname, filename, meta_copy = \
                        self._get_app_and_icon_and_deb_from_trans(trans)
                    self.reload()
                    self.install(app, iconname, filename, [], [],
                                 metadata=meta_copy)
                    return
            # Finish a cancelled installation before resuming. If the
            # user e.g. rebooted during a debconf question apt
            # will hang and the user is required to call
            # dpkg --configure -a, see LP#659438
            elif (trans.error and
                  trans.error.code == enums.ERROR_INCOMPLETE_INSTALL):
                action = _("Repair")
                res = self._show_transaction_failed_dialog(trans, enum,
                                                           action)
                if res == "yes":
                    self.fix_incomplete_install()
                    return

            elif (not "sc_add_repo_and_install_ignore_errors" in
                  trans.meta_data):
                self._show_transaction_failed_dialog(trans, enum)

        # send finished signal, use "" here instead of None, because
        # dbus mangles a None to a str("None")
        pkgname = ""
        try:
            pkgname = trans.meta_data["sc_pkgname"]
            del self.pending_transactions[pkgname]
            self.emit("transaction-progress-changed", pkgname, 100)
        except KeyError:
            pass
        # if it was a cache-reload, trigger a-x-i update
        if trans.role == enums.ROLE_UPDATE_CACHE:
            if enum == enums.EXIT_SUCCESS:
                self.update_xapian_index()
            self.emit("reload-finished", trans, enum != enums.EXIT_FAILED)
        # send appropriate signals
        self.inject_fake_transactions_and_emit_changed_signal()
        self.emit("transaction-finished", TransactionFinishedResult(trans,
            enum != enums.EXIT_FAILED))

    @inline_callbacks
    def _config_file_conflict(self, transaction, old, new):
        reply = self.ui.ask_config_file_conflict(old, new)
        if reply == "replace":
            yield transaction.resolve_config_file_conflict(old, "replace",
                                                           defer=True)
        elif reply == "keep":
            yield transaction.resolve_config_file_conflict(old, "keep",
                                                           defer=True)
        else:
            raise Exception(
                "unknown reply: '%s' in _ask_config_file_conflict " % reply)

    @inline_callbacks
    def _medium_required(self, transaction, medium, drive):
        res = self.ui.ask_medium_required(medium, drive)
        if res:
            yield transaction.provide_medium(medium, defer=True)
        else:
            yield transaction.cancel(defer=True)

    @inline_callbacks
    def _run_transaction(self, trans, pkgname, appname, iconname,
                         metadata=None):
        # connect signals
        trans.connect("config-file-conflict", self._config_file_conflict)
        trans.connect("medium-required", self._medium_required)
        trans.connect("finished", self._on_trans_finished)
        try:
            # set appname/iconname/pkgname only if we actually have one
            if appname:
                yield trans.set_meta_data(sc_appname=appname, defer=True)
            if iconname:
                yield trans.set_meta_data(sc_iconname=iconname, defer=True)
            # we do not always have a pkgname, e.g. "cache_update" does not
            if pkgname:
                # ensure the metadata is just the pkgname
                sc_pkgname = pkgname.split("/")[0].split("=")[0]
                yield trans.set_meta_data(sc_pkgname=sc_pkgname, defer=True)
                # setup debconf only if we have a pkg
                yield trans.set_debconf_frontend("gnome", defer=True)
                trans.set_remove_obsoleted_depends(True, defer=True)
                self._progress_signal = trans.connect("progress-changed",
                    self._on_progress_changed)
                self.pending_transactions[pkgname] = TransactionProgress(trans)
            # generic metadata
            if metadata:
                yield trans.set_meta_data(defer=True, **metadata)
            yield trans.run(defer=True)
        except Exception as error:
            self._on_trans_error(error, trans, pkgname)
            # on error we need to clean the pending purchases
            self._clean_pending_purchases(pkgname)
        # on success the pending purchase is cleaned when the package
        # that was purchased finished installing
        if trans.role == enums.ROLE_INSTALL_PACKAGES:
            self._clean_pending_purchases(pkgname)

    def _clean_pending_purchases(self, pkgname):
        if pkgname and pkgname in self.pending_purchases:
            del self.pending_purchases[pkgname]

    def _on_trans_error(self, error, trans, pkgname=None):
        self._logger.warn("_on_trans_error: '%r' '%s' '%s'" % (
                error, trans, pkgname))
        # re-enable the action button again if anything went wrong
        result = TransactionFinishedResult(None, False)
        result.pkgname = pkgname

        # clean up pending transactions
        if pkgname and pkgname in self.pending_transactions:
            del self.pending_transactions[pkgname]

        # calculate a dupes_signature here to have different buckets
        # on errors.ubuntu.com for the different crash types
        if trans:
            error_code = trans.error_code
        else:
            error_code = "no-transaction"
        dupes_signature = "software-center:trans-failed:%s" % error_code

        self.emit("transaction-stopped", result)
        if isinstance(error, dbus.DBusException):
            # ignore errors that the user knows about already (like
            # that he entered a wrong password or that he does not
            # care about (like no-reply)
            name = error.get_dbus_name()
            if name in ["org.freedesktop.PolicyKit.Error.NotAuthorized",
                        "org.freedesktop.DBus.Error.NoReply"]:
                return
            # we want to give some advice here to the user but also
            # report this via apport
            if name in ["org.freedesktop.PolicyKit.Error.Failed"]:
                summary = _("Authentication Error")
                text = _("Software can't be installed or removed because "
                         "the authentication service is not available. "
                         "(%s") % error
                # send to apport for reporting
                self._call_apport_recoverable_error(
                    text, error, dupes_signature)
                # ... and display as a dialog
                self.ui.error(None, summary, text)
                return

        # lintian errors are ignored and not send to apport_recoverable_error
        # and dpkg errors as well as they will already be recorded separately
        # by apt itself
        if error_code in (enums.ERROR_INVALID_PACKAGE_FILE,
                                enums.ERROR_PACKAGE_MANAGER_FAILED):
            return

        # show a apport recoverable error dialog to the user as we want
        # to know about these issues
        self._call_apport_recoverable_error(
            _("There was an error submitting the transaction"),
            error,
            dupes_signature)

    def _call_apport_recoverable_error(self, text, error, dupes_signature):
        """Call apport's recoverable_problem dialog """

        # ensure we have a proper exception string in the report
        if isinstance(error, Exception):
            error = traceback.format_exc(error)

        # mvo: I don't think we need to send "Package\0software-center",
        #      apport should figure this out itself
        data = ("DialogBody\0%(text)s\0"
                "Traceback\0%(error)s\0"
                "DuplicateSignature\0%(dupes_signature)s" % {
                'text': text,
                'error': error,
                'dupes_signature': dupes_signature,
                })
        # This will be quick as it just writes the report file. Then
        # the report gets picked up asynchronously by a inotify watch
        # and displayed to the user in a separate process.
        p = Popen(
            [APPORT_RECOVERABLE_ERROR], stdin=PIPE, stdout=PIPE, stderr=PIPE)

        (stdout, stderr) = p.communicate(input=data)
        if p.returncode != 0:
            logging.warn("%s returned '%s' ('%s', '%s')" % (
                    APPORT_RECOVERABLE_ERROR, p.returncode, stdout, stderr))


if __name__ == "__main__":
    #c = client.AptClient()
    #c.remove_packages(["4g8"], remove_unused_dependencies=True)
    backend = AptdaemonBackend()
    #backend.reload()
    backend.enable_component("multiverse")
    from gi.repository import Gtk
    Gtk.main()
