// Mgmt
// Copyright (C) James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// 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 3 of the License, or
// (at your option) any later version.
//
// 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, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.

// Package packagekit provides an interface to connect to the packagekit daemon.
// See: https://www.freedesktop.org/software/PackageKit/gtk-doc/index.html for
// more information.
package packagekit

import (
	"context"
	"fmt"
	"runtime"
	"strings"

	engineUtil "github.com/purpleidea/mgmt/engine/util"
	"github.com/purpleidea/mgmt/util"
	archUtil "github.com/purpleidea/mgmt/util/arch"
	"github.com/purpleidea/mgmt/util/errwrap"

	"github.com/godbus/dbus/v5"
)

// constants which might need to be tweaked or which contain special dbus
// strings.
const (
	// FIXME: if PkBufferSize is too low, install seems to drop signals
	PkBufferSize = 1000
	// TODO: the PkSignalTimeout value might be too low
	PkSignalPackageTimeout = 60 // 60 seconds, arbitrary
	PkSignalDestroyTimeout = 15 // 15 seconds, arbitrary
	PkPath                 = "/org/freedesktop/PackageKit"
	PkIface                = "org.freedesktop.PackageKit"
	PkIfaceTransaction     = PkIface + ".Transaction"
)

// type enum_filter uint64
// https://github.com/hughsie/PackageKit/blob/master/lib/packagekit-glib2/pk-enum.c
const ( //static const PkEnumMatch enum_filter[]
	PkFilterEnumUnknown        uint64 = 1 << iota // "unknown"
	PkFilterEnumNone                              // "none"
	PkFilterEnumInstalled                         // "installed"
	PkFilterEnumNotInstalled                      // "~installed"
	PkFilterEnumDevelopment                       // "devel"
	PkFilterEnumNotDevelopment                    // "~devel"
	PkFilterEnumGui                               // "gui"
	PkFilterEnumNotGui                            // "~gui"
	PkFilterEnumFree                              // "free"
	PkFilterEnumNotFree                           // "~free"
	PkFilterEnumVisible                           // "visible"
	PkFilterEnumNotVisible                        // "~visible"
	PkFilterEnumSupported                         // "supported"
	PkFilterEnumNotSupported                      // "~supported"
	PkFilterEnumBasename                          // "basename"
	PkFilterEnumNotBasename                       // "~basename"
	PkFilterEnumNewest                            // "newest"
	PkFilterEnumNotNewest                         // "~newest"
	PkFilterEnumArch                              // "arch"
	PkFilterEnumNotArch                           // "~arch"
	PkFilterEnumSource                            // "source"
	PkFilterEnumNotSource                         // "~source"
	PkFilterEnumCollections                       // "collections"
	PkFilterEnumNotCollections                    // "~collections"
	PkFilterEnumApplication                       // "application"
	PkFilterEnumNotApplication                    // "~application"
	PkFilterEnumDownloaded                        // "downloaded"
	PkFilterEnumNotDownloaded                     // "~downloaded"
)

// constants from packagekit c library.
const ( //static const PkEnumMatch enum_transaction_flag[]
	PkTransactionFlagEnumNone           uint64 = 1 << iota // "none"
	PkTransactionFlagEnumOnlyTrusted                       // "only-trusted"
	PkTransactionFlagEnumSimulate                          // "simulate"
	PkTransactionFlagEnumOnlyDownload                      // "only-download"
	PkTransactionFlagEnumAllowReinstall                    // "allow-reinstall"
	PkTransactionFlagEnumJustReinstall                     // "just-reinstall"
	PkTransactionFlagEnumAllowDowngrade                    // "allow-downgrade"
)

// constants from packagekit c library.
const ( //typedef enum
	PkErrorEnumUnknown uint32 = iota
	PkErrorEnumOOM
	PkErrorEnumNoNetwork
	PkErrorEnumNotSupported
	PkErrorEnumInternalError
	PkErrorEnumGPGFailure
	PkErrorEnumPackageIDInvalid
	PkErrorEnumPackageNotInstalled
	PkErrorEnumPackageNotFound
	PkErrorEnumPackageAlreadyInstalled
	PkErrorEnumPackageDownloadFailed
	PkErrorEnumGroupNotFound
	PkErrorEnumGroupListInvalid
	PkErrorEnumDepResolutionFailed
	PkErrorEnumFilterInvalid
	PkErrorEnumCreateThreadFailed
	PkErrorEnumTransactionError
	PkErrorEnumTransactionCancelled
	PkErrorEnumNoCache
	PkErrorEnumRepoNotFound
	PkErrorEnumCannotRemoveSystemPackage
	PkErrorEnumProcessKill
	PkErrorEnumFailedInitialization
	PkErrorEnumFailedFinalise
	PkErrorEnumFailedConfigParsing
	PkErrorEnumCannotCancel
	PkErrorEnumCannotGetLock
	PkErrorEnumNoPackagesToUpdate
	PkErrorEnumCannotWriteRepoConfig
	PkErrorEnumLocalInstallFailed
	PkErrorEnumBadGPGSignature
	PkErrorEnumMissingGPGSignature
	PkErrorEnumCannotInstallSourcePackages
	PkErrorEnumRepoConfigurationError
	PkErrorEnumNoLicenseAgreement
	PkErrorEnumFileConflicts
	PkErrorEnumPackageConflicts
	PkErrorEnumRepoNotAvailable
	PkErrorEnumInvalidPackageFile
	PkErrorEnumPackageInstallBlocked
	PkErrorEnumPackageCorrupt
	PkErrorEnumAllPackagesAlreadyInstalled
	PkErrorEnumFileNotFound
	PkErrorEnumNoMoreMirrorsToTry
	PkErrorEnumNoDistroUpgradeData
	PkErrorEnumIncompatibleArchitecture
	PkErrorEnumNoSpaceOnDevice
	PkErrorEnumMediaChangeRequired
	PkErrorEnumNotAuthorized
	PkErrorEnumUpdateNotFound
	PkErrorEnumCannotInstallRepoUnsigned
	PkErrorEnumCannotUpdateRepoUnsigned
	PkErrorEnumCannotGetFilelist
	PkErrorEnumCannotGetRequires
	PkErrorEnumCannotDisableRepository
	PkErrorEnumRestrictedDownload
	PkErrorEnumPackageFailedToConfigure
	PkErrorEnumPackageFailedToBuild
	PkErrorEnumPackageFailedToInstall
	PkErrorEnumPackageFailedToRemove
	PkErrorEnumUpdateFailedDueToRunningProcess
	PkErrorEnumPackageDatabaseChanged
	PkErrorEnumProvideTypeNotSupported
	PkErrorEnumInstallRootInvalid
	PkErrorEnumCannotFetchSources
	PkErrorEnumCancelledPriority
	PkErrorEnumUnfinishedTransaction
	PkErrorEnumLockRequired
	PkErrorEnumRepoAlreadySet
)

// constants from packagekit c library.
const ( //typedef enum
	PkInfoEnumUnknown uint64 = 1 << iota
	PkInfoEnumInstalled
	PkInfoEnumAvailable
	PkInfoEnumLow
	PkInfoEnumEnhancement
	PkInfoEnumNormal
	PkInfoEnumBugfix
	PkInfoEnumImportant
	PkInfoEnumSecurity
	PkInfoEnumBlocked
	PkInfoEnumDownloading
	PkInfoEnumUpdating
	PkInfoEnumInstalling
	PkInfoEnumRemoving
	PkInfoEnumCleanup
	PkInfoEnumObsoleting
	PkInfoEnumCollectionInstalled
	PkInfoEnumCollectionAvailable
	PkInfoEnumFinished
	PkInfoEnumReinstalling
	PkInfoEnumDowngrading
	PkInfoEnumPreparing
	PkInfoEnumDecompressing
	PkInfoEnumUntrusted
	PkInfoEnumTrusted
	PkInfoEnumUnavailable
	PkInfoEnumLast
)

// PkError is an error emitted by the PackageKit ErrorCode signal.
type PkError struct {
	Code    uint32
	Details string
}

// Error is the standard function that fulfills the interface.
func (obj *PkError) Error() string {
	if obj.Details == "" {
		return fmt.Sprintf("packagekit error %d", obj.Code)
	}
	return fmt.Sprintf("packagekit error %d: %s", obj.Code, obj.Details)
}

func newPkError(body []interface{}) error {
	if len(body) != 2 {
		return fmt.Errorf("error in body: %v", body)
	}
	code, ok := body[0].(uint32)
	if !ok {
		return fmt.Errorf("error in body: %v", body)
	}
	details, ok := body[1].(string)
	if !ok {
		return fmt.Errorf("error in body: %v", body)
	}
	return &PkError{
		Code:    code,
		Details: details,
	}
}

// Conn is a wrapper struct so we can pass bus connection around in the struct.
type Conn struct {
	conn *dbus.Conn

	Debug bool
	Logf  func(format string, v ...interface{})
}

// PkPackageIDActionData is a struct that is returned by PackagesToPackageIDs in
// the map values.
type PkPackageIDActionData struct {
	Found     bool
	Installed bool
	Version   string
	PackageID string
	Newest    bool
}

// NewBus returns a new bus connection.
func NewBus() (*Conn, error) {
	// if we share the bus with others, we will get each others messages!!
	bus, err := util.SystemBusPrivateUsable() // don't share the bus connection!
	if err != nil {
		return nil, errwrap.Wrapf(err, "can't connect to system bus")
	}
	return &Conn{
		conn: bus,
	}, nil
}

// GetBus gets the dbus connection object.
func (obj *Conn) GetBus() *dbus.Conn {
	return obj.conn
}

// Close closes the dbus connection object.
func (obj *Conn) Close() error {
	return obj.conn.Close()
}

// matchSignal is an internal helper to add signal matches to the bus. It should
// only be called once.
func (obj *Conn) matchSignal(ch chan *dbus.Signal, path dbus.ObjectPath, iface string, signals []string) (func() error, error) {
	if obj.Debug {
		obj.Logf("matchSignal(%v, %v, %s, %v)", ch, path, iface, signals)
	}
	// eg: gdbus monitor --system --dest org.freedesktop.PackageKit --object-path /org/freedesktop/PackageKit | grep <signal>
	bus := obj.GetBus().BusObject()
	var argsList []string
	signalRegistered := false
	// cleanup function should be called when done or when AddMatch errors
	removeSignals := func() error {
		// Unregister ch from godbus first so no more signals are
		// dispatched to it, then drop the bus-daemon match rules.
		if signalRegistered {
			obj.GetBus().RemoveSignal(ch)
		}
		var errList error
		for i := len(argsList) - 1; i >= 0; i-- { // last in first out
			call := bus.Call(engineUtil.DBusRemoveMatch, 0, argsList[i])
			errList = errwrap.Append(errList, call.Err)
		}
		return errList
	}
	// TODO: if we make this call many times, we seem to receive signals
	// that many times... Maybe this should be an object singleton?
	var call *dbus.Call
	if len(signals) == 0 {
		args := fmt.Sprintf("type='signal', path='%s', interface='%s'", path, iface)
		argsList = append(argsList, args)
		call = bus.Call(engineUtil.DBusAddMatch, 0, args)
	} else {
		for _, signal := range signals {
			args := fmt.Sprintf("type='signal', path='%s', interface='%s', member='%s'", path, iface, signal)
			argsList = append(argsList, args)
			if call = bus.Call(engineUtil.DBusAddMatch, 0, args); call.Err != nil {
				break // fail if any one fails
			}
		}
	}
	if call.Err != nil {
		defer removeSignals() // ignore the error
		return nil, call.Err
	}

	// The caller has to make sure that ch is sufficiently buffered; if a
	// message arrives when a write to c is not possible, it is discarded!
	// This can be disastrous if we're waiting for a "Finished" signal!
	obj.GetBus().Signal(ch)
	signalRegistered = true
	return removeSignals, nil
}

// WatchChanges gets a signal anytime an event happens. The caller must invoke
// the returned cleanup function when it is done watching, or signal matches and
// channel registrations will leak.
func (obj *Conn) WatchChanges() (chan *dbus.Signal, func() error, error) {
	ch := make(chan *dbus.Signal, PkBufferSize)
	// NOTE: the TransactionListChanged signal fires much more frequently,
	// but with much less specificity. If we're missing events, report the
	// issue upstream! The UpdatesChanged signal is what hughsie suggested
	var signal = "UpdatesChanged"
	removeSignals, err := obj.matchSignal(ch, PkPath, PkIface, []string{signal})
	if err != nil {
		return nil, nil, err
	}
	return ch, removeSignals, nil
}

// CreateTransaction creates and returns a transaction path.
func (obj *Conn) CreateTransaction() (dbus.ObjectPath, error) {
	if obj.Debug {
		obj.Logf("CreateTransaction()")
	}
	var interfacePath dbus.ObjectPath
	bus := obj.GetBus().Object(PkIface, PkPath)
	call := bus.Call(fmt.Sprintf("%s.CreateTransaction", PkIface), 0)
	if call.Err != nil {
		return "", call.Err
	}
	if err := call.Store(&interfacePath); err != nil {
		return "", err
	}

	if obj.Debug {
		obj.Logf("CreateTransaction(): %v", interfacePath)
	}
	return interfacePath, nil
}

// ResolvePackages runs the PackageKit Resolve method and returns the result.
func (obj *Conn) ResolvePackages(ctx context.Context, packages []string, filter uint64) ([]string, error) {
	packageIDs := []string{}
	ch := make(chan *dbus.Signal, PkBufferSize)   // we need to buffer :(
	interfacePath, err := obj.CreateTransaction() // emits Destroy on close
	if err != nil {
		return []string{}, err
	}

	// add signal matches for Package and Finished which will always be last
	var signals = []string{"Package", "Finished", "ErrorCode", "Destroy"}
	removeSignals, err := obj.matchSignal(ch, interfacePath, PkIfaceTransaction, signals)
	if err != nil {
		return nil, err
	}
	defer removeSignals()
	if obj.Debug {
		obj.Logf("ResolvePackages(): Object(%s, %v)", PkIface, interfacePath)
	}
	bus := obj.GetBus().Object(PkIface, interfacePath) // pass in found transaction path
	call := bus.Call(FmtTransactionMethod("Resolve"), 0, filter, packages)
	if obj.Debug {
		obj.Logf("ResolvePackages(): Call: Success!")
	}
	if call.Err != nil {
		return []string{}, call.Err
	}
loop:
	for {
		// FIXME: add a timeout option to error in case signals are dropped!
		select {
		case signal, ok := <-ch:
			if !ok {
				// channel closed, e.g. the bus connection died
				return []string{}, fmt.Errorf("signal channel closed unexpectedly")
			}
			if obj.Debug {
				obj.Logf("ResolvePackages(): Signal: %+v", signal)
			}
			if signal.Path != interfacePath {
				obj.Logf("woops: Signal.Path: %+v", signal.Path)
				continue loop
			}

			if signal.Name == FmtTransactionMethod("ErrorCode") {
				return []string{}, newPkError(signal.Body)
			} else if signal.Name == FmtTransactionMethod("Package") {
				//pkg_int, ok := signal.Body[0].(int)
				packageID, ok := signal.Body[1].(string)
				// format is: name;version;arch;data
				if !ok {
					continue loop
				}
				//comment, ok := signal.Body[2].(string)
				for _, p := range packageIDs {
					if packageID == p {
						continue loop // duplicate!
					}
				}
				packageIDs = append(packageIDs, packageID)
			} else if signal.Name == FmtTransactionMethod("Finished") {
				// TODO: should we wait for the Destroy signal?
				break loop
			} else if signal.Name == FmtTransactionMethod("Destroy") {
				// should already be broken
				break loop
			} else {
				return []string{}, fmt.Errorf("error in body: %v", signal.Body)
			}

		case <-ctx.Done(): // the engine cancelled us, e.g. a slow network
			return []string{}, ctx.Err()
		}
	}
	return packageIDs, nil
}

// IsInstalledList queries a list of packages to see if they are installed.
func (obj *Conn) IsInstalledList(ctx context.Context, packages []string) ([]bool, error) {
	var filter uint64          // initializes at the "zero" value of 0
	filter += PkFilterEnumArch // always search in our arch
	packageIDs, err := obj.ResolvePackages(ctx, packages, filter)
	if err != nil {
		return nil, errwrap.Wrapf(err, "error resolving packages")
	}

	var m = make(map[string]int)
	for _, packageID := range packageIDs {
		s := strings.Split(packageID, ";")
		//if len(s) != 4 { continue } // this would be a bug!
		pkg := s[0]
		flags := strings.Split(s[3], ":")
		for _, f := range flags {
			if f == "installed" {
				if _, exists := m[pkg]; !exists {
					m[pkg] = 0
				}
				m[pkg]++ // if we see pkg installed, increment
				break
			}
		}
	}

	r := make([]bool, 0, len(packages))
	for _, p := range packages {
		if value, exists := m[p]; exists {
			r = append(r, value > 0) // at least 1 means installed
		} else {
			r = append(r, false)
		}
	}
	return r, nil
}

// IsInstalled returns if a package is installed.
// TODO: this could be optimized by making the resolve call directly
func (obj *Conn) IsInstalled(ctx context.Context, pkg string) (bool, error) {
	p, err := obj.IsInstalledList(ctx, []string{pkg})
	if err != nil {
		return false, err
	}
	if len(p) != 1 {
		return false, fmt.Errorf("unexpected result count %d for package %s", len(p), pkg)
	}
	return p[0], nil
}

// RefreshCache refreshes the package cache on its own transaction. PackageKit
// transactions are single-shot, so callers that also want to install or remove
// packages must do that on a separate transaction.
func (obj *Conn) RefreshCache(force bool) error {
	ch := make(chan *dbus.Signal, PkBufferSize)   // we need to buffer :(
	interfacePath, err := obj.CreateTransaction() // emits Destroy on close
	if err != nil {
		return err
	}

	var signals = []string{"RepoDetail", "ErrorCode", "Finished", "Destroy"} // "Progress", "Status" ?
	removeSignals, err := obj.matchSignal(ch, interfacePath, PkIfaceTransaction, signals)
	if err != nil {
		return err
	}
	defer removeSignals()

	bus := obj.GetBus().Object(PkIface, interfacePath) // pass in found transaction path
	call := bus.Call(FmtTransactionMethod("RefreshCache"), 0, force)
	if call.Err != nil {
		return call.Err
	}
loop:
	for {
		// FIXME: add a timeout option to error in case signals are dropped!
		select {
		case signal, ok := <-ch:
			if !ok {
				// channel closed, e.g. the bus connection died
				return fmt.Errorf("signal channel closed unexpectedly")
			}
			if signal.Path != interfacePath {
				obj.Logf("woops: Signal.Path: %+v", signal.Path)
				continue loop
			}

			if signal.Name == FmtTransactionMethod("ErrorCode") {
				return newPkError(signal.Body)
			} else if signal.Name == FmtTransactionMethod("RepoDetail") {
				continue loop
			} else if signal.Name == FmtTransactionMethod("Finished") {
				// TODO: should we wait for the Destroy signal?
				break loop
			} else if signal.Name == FmtTransactionMethod("Destroy") {
				// should already be broken
				break loop
			} else {
				return fmt.Errorf("error in body: %v", signal.Body)
			}
		}
	}
	return nil
}

// InstallPackages installs a list of packages by packageID.
func (obj *Conn) InstallPackages(packageIDs []string, transactionFlags uint64) error {

	if err := obj.RefreshCache(false); err != nil {
		return errwrap.Wrapf(err, "can't refresh cache")
	}

	ch := make(chan *dbus.Signal, PkBufferSize)   // we need to buffer :(
	interfacePath, err := obj.CreateTransaction() // emits Destroy on close
	if err != nil {
		return err
	}

	var signals = []string{"Package", "ErrorCode", "Finished", "Destroy"} // "ItemProgress", "Status" ?
	removeSignals, err := obj.matchSignal(ch, interfacePath, PkIfaceTransaction, signals)
	if err != nil {
		return err
	}
	defer removeSignals()

	bus := obj.GetBus().Object(PkIface, interfacePath) // pass in found transaction path
	call := bus.Call(FmtTransactionMethod("InstallPackages"), 0, transactionFlags, packageIDs)
	if call.Err != nil {
		return call.Err
	}
	timeout := -1 // disabled initially
	finished := false
loop:
	for {
		select {
		case signal, ok := <-ch:
			if !ok {
				// channel closed, e.g. the bus connection died
				return fmt.Errorf("signal channel closed unexpectedly")
			}
			if signal.Path != interfacePath {
				obj.Logf("woops: Signal.Path: %+v", signal.Path)
				continue loop
			}

			if signal.Name == FmtTransactionMethod("ErrorCode") {
				return newPkError(signal.Body)
			} else if signal.Name == FmtTransactionMethod("Package") {
				// a package was installed...
				// only start the timer once we're here...
				timeout = PkSignalPackageTimeout
			} else if signal.Name == FmtTransactionMethod("Finished") {
				finished = true
				timeout = PkSignalDestroyTimeout // wait a bit
			} else if signal.Name == FmtTransactionMethod("Destroy") {
				return nil // success
			} else {
				return fmt.Errorf("error in body: %v", signal.Body)
			}
		case <-util.TimeAfterOrBlock(timeout):
			if finished {
				obj.Logf("Timeout: InstallPackages: Waiting for 'Destroy'")
				return nil // got tired of waiting for Destroy
			}
			return fmt.Errorf("timeout installing packages: %s", strings.Join(packageIDs, ", "))
		}
	}
}

// RemovePackages removes a list of packages by packageID.
func (obj *Conn) RemovePackages(packageIDs []string, transactionFlags uint64) error {

	var allowDeps = true                          // TODO: configurable
	var autoremove = false                        // unsupported on GNU/Linux
	ch := make(chan *dbus.Signal, PkBufferSize)   // we need to buffer :(
	interfacePath, err := obj.CreateTransaction() // emits Destroy on close
	if err != nil {
		return err
	}

	var signals = []string{"Package", "ErrorCode", "Finished", "Destroy"} // "ItemProgress", "Status" ?
	removeSignals, err := obj.matchSignal(ch, interfacePath, PkIfaceTransaction, signals)
	if err != nil {
		return err
	}
	defer removeSignals()

	bus := obj.GetBus().Object(PkIface, interfacePath) // pass in found transaction path
	call := bus.Call(FmtTransactionMethod("RemovePackages"), 0, transactionFlags, packageIDs, allowDeps, autoremove)
	if call.Err != nil {
		return call.Err
	}
loop:
	for {
		// FIXME: add a timeout option to error in case signals are dropped!
		select {
		case signal, ok := <-ch:
			if !ok {
				// channel closed, e.g. the bus connection died
				return fmt.Errorf("signal channel closed unexpectedly")
			}
			if signal.Path != interfacePath {
				obj.Logf("woops: Signal.Path: %+v", signal.Path)
				continue loop
			}

			if signal.Name == FmtTransactionMethod("ErrorCode") {
				return newPkError(signal.Body)
			} else if signal.Name == FmtTransactionMethod("Package") {
				// a package was uninstalled...
				continue loop
			} else if signal.Name == FmtTransactionMethod("Finished") {
				// TODO: should we wait for the Destroy signal?
				break loop
			} else if signal.Name == FmtTransactionMethod("Destroy") {
				// should already be broken
				break loop
			} else {
				return fmt.Errorf("error in body: %v", signal.Body)
			}
		}
	}
	return nil
}

// UpdatePackages updates a list of packages to versions that are specified.
func (obj *Conn) UpdatePackages(packageIDs []string, transactionFlags uint64) error {
	ch := make(chan *dbus.Signal, PkBufferSize) // we need to buffer :(
	interfacePath, err := obj.CreateTransaction()
	if err != nil {
		return err
	}

	var signals = []string{"Package", "ErrorCode", "Finished", "Destroy"} // "ItemProgress", "Status" ?
	removeSignals, err := obj.matchSignal(ch, interfacePath, PkIfaceTransaction, signals)
	if err != nil {
		return err
	}
	defer removeSignals()

	bus := obj.GetBus().Object(PkIface, interfacePath) // pass in found transaction path
	call := bus.Call(FmtTransactionMethod("UpdatePackages"), 0, transactionFlags, packageIDs)
	if call.Err != nil {
		return call.Err
	}
loop:
	for {
		// FIXME: add a timeout option to error in case signals are dropped!
		select {
		case signal, ok := <-ch:
			if !ok {
				// channel closed, e.g. the bus connection died
				return fmt.Errorf("signal channel closed unexpectedly")
			}
			if signal.Path != interfacePath {
				obj.Logf("woops: Signal.Path: %+v", signal.Path)
				continue loop
			}

			if signal.Name == FmtTransactionMethod("ErrorCode") {
				return newPkError(signal.Body)
			} else if signal.Name == FmtTransactionMethod("Package") {
			} else if signal.Name == FmtTransactionMethod("Finished") {
				// TODO: should we wait for the Destroy signal?
				break loop
			} else if signal.Name == FmtTransactionMethod("Destroy") {
				// should already be broken
				break loop
			} else {
				return fmt.Errorf("error in body: %v", signal.Body)
			}
		}
	}
	return nil
}

// GetFilesByPackageID gets the list of files that are contained inside a list
// of packageIDs.
func (obj *Conn) GetFilesByPackageID(ctx context.Context, packageIDs []string) (files map[string][]string, err error) {
	// NOTE: the maximum number of files in an RPM is 52116 in Fedora 23
	// https://gist.github.com/purpleidea/b98e60dcd449e1ac3b8a
	ch := make(chan *dbus.Signal, PkBufferSize) // we need to buffer :(
	interfacePath, err := obj.CreateTransaction()
	if err != nil {
		return
	}

	var signals = []string{"Files", "ErrorCode", "Finished", "Destroy"} // "ItemProgress", "Status" ?
	removeSignals, err := obj.matchSignal(ch, interfacePath, PkIfaceTransaction, signals)
	if err != nil {
		return
	}
	defer removeSignals()

	bus := obj.GetBus().Object(PkIface, interfacePath) // pass in found transaction path
	call := bus.Call(FmtTransactionMethod("GetFiles"), 0, packageIDs)
	if call.Err != nil {
		err = call.Err
		return
	}
	files = make(map[string][]string)
loop:
	for {
		// FIXME: add a timeout option to error in case signals are dropped!
		select {
		case signal, ok := <-ch:
			if !ok {
				// channel closed, e.g. the bus connection died
				err = fmt.Errorf("signal channel closed unexpectedly")
				return
			}

			if signal.Path != interfacePath {
				obj.Logf("woops: Signal.Path: %+v", signal.Path)
				continue loop
			}

			if signal.Name == FmtTransactionMethod("ErrorCode") {
				err = newPkError(signal.Body)
				return

				// one signal returned per packageID found...
			} else if signal.Name == FmtTransactionMethod("Files") {
				if len(signal.Body) != 2 { // bad data
					continue loop
				}
				var ok bool
				var key string
				var fileList []string
				if key, ok = signal.Body[0].(string); !ok {
					continue loop
				}
				if fileList, ok = signal.Body[1].([]string); !ok {
					continue loop // failed conversion
				}
				files[key] = fileList // build up map
			} else if signal.Name == FmtTransactionMethod("Finished") {
				// TODO: should we wait for the Destroy signal?
				break loop
			} else if signal.Name == FmtTransactionMethod("Destroy") {
				// should already be broken
				break loop
			} else {
				err = fmt.Errorf("error in body: %v", signal.Body)
				return
			}

		case <-ctx.Done(): // the engine cancelled us, e.g. a slow network
			err = ctx.Err()
			return
		}
	}
	return
}

// GetUpdates gets a list of packages that are installed and which can be
// updated, mod filter.
func (obj *Conn) GetUpdates(filter uint64) ([]string, error) {
	if obj.Debug {
		obj.Logf("GetUpdates()")
	}
	packageIDs := []string{}
	ch := make(chan *dbus.Signal, PkBufferSize) // we need to buffer :(
	interfacePath, err := obj.CreateTransaction()
	if err != nil {
		return nil, err
	}

	var signals = []string{"Package", "ErrorCode", "Finished", "Destroy"} // "ItemProgress" ?
	removeSignals, err := obj.matchSignal(ch, interfacePath, PkIfaceTransaction, signals)
	if err != nil {
		return nil, err
	}
	defer removeSignals()

	bus := obj.GetBus().Object(PkIface, interfacePath) // pass in found transaction path
	call := bus.Call(FmtTransactionMethod("GetUpdates"), 0, filter)
	if call.Err != nil {
		return nil, call.Err
	}
loop:
	for {
		// FIXME: add a timeout option to error in case signals are dropped!
		select {
		case signal, ok := <-ch:
			if !ok {
				// channel closed, e.g. the bus connection died
				return nil, fmt.Errorf("signal channel closed unexpectedly")
			}
			if signal.Path != interfacePath {
				obj.Logf("woops: Signal.Path: %+v", signal.Path)
				continue loop
			}

			if signal.Name == FmtTransactionMethod("ErrorCode") {
				return nil, newPkError(signal.Body)
			} else if signal.Name == FmtTransactionMethod("Package") {

				//pkg_int, ok := signal.Body[0].(int)
				packageID, ok := signal.Body[1].(string)
				// format is: name;version;arch;data
				if !ok {
					continue loop
				}
				//comment, ok := signal.Body[2].(string)
				for _, p := range packageIDs { // optional?
					if packageID == p {
						continue loop // duplicate!
					}
				}
				packageIDs = append(packageIDs, packageID)
			} else if signal.Name == FmtTransactionMethod("Finished") {
				// TODO: should we wait for the Destroy signal?
				break loop
			} else if signal.Name == FmtTransactionMethod("Destroy") {
				// should already be broken
				break loop
			} else {
				return nil, fmt.Errorf("error in body: %v", signal.Body)
			}
		}
	}
	return packageIDs, nil
}

// PackagesToPackageIDs is a helper function that *might* be generally useful
// outside mgmt. The packageMap input has the package names as keys and
// requested states as values. These states can be: installed, uninstalled,
// newest or a requested version str.
func (obj *Conn) PackagesToPackageIDs(ctx context.Context, packageMap map[string]string, filter uint64) (map[string]*PkPackageIDActionData, error) {
	count := 0
	packages := make([]string, len(packageMap))
	for k := range packageMap { // lol, golang has no hash.keys() function!
		packages[count] = k
		count++
	}

	if !(filter&PkFilterEnumArch == PkFilterEnumArch) {
		filter += PkFilterEnumArch // always search in our arch
	}

	if obj.Debug {
		obj.Logf("PackagesToPackageIDs(): %s", strings.Join(packages, ", "))
	}
	resolved, err := obj.ResolvePackages(ctx, packages, filter)
	if err != nil {
		return nil, errwrap.Wrapf(err, "error resolving")
	}

	found := make([]bool, count) // default false
	installed := make([]bool, count)
	version := make([]string, count)
	usePackageID := make([]string, count)
	newest := make([]bool, count) // default true
	for i := range newest {
		newest[i] = true // assume, for now
	}
	var index int

	for _, packageID := range resolved {
		index = -1
		//obj.Logf("* %v", packageID)
		// format is: name;version;arch;data
		s := strings.Split(packageID, ";")
		//if len(s) != 4 { continue } // this would be a bug!
		pkg, ver, arch, data := s[0], s[1], s[2], s[3]
		// we might need to allow some of this, eg: i386 .deb on amd64
		b, err := IsMyArch(arch)
		if err != nil {
			return nil, errwrap.Wrapf(err, "arch error")
		} else if !b {
			continue
		}

		for i := range packages { // find pkg if it exists
			if pkg == packages[i] {
				index = i
			}
		}
		if index == -1 { // can't find what we're looking for
			continue
		}
		state := packageMap[pkg] // lookup the requested state/version
		if state == "" {
			return nil, fmt.Errorf("empty package state for: `%s`", pkg)
		}
		found[index] = true
		stateIsVersion := (state != "installed" && state != "uninstalled" && state != "newest") // must be a ver. string

		if stateIsVersion {
			if state == ver && ver != "" { // we match what we want...
				usePackageID[index] = packageID
			}
		}

		if FlagInData("installed", data) {
			installed[index] = true
			version[index] = ver
			// state of "uninstalled" matched during CheckApply, and
			// states of "installed" and "newest" for fileList
			if !stateIsVersion {
				usePackageID[index] = packageID // save for later
			}
		} else { // not installed...
			if !stateIsVersion {
				// if there is more than one result, eg: there
				// is the old and newest version of a package,
				// then this section can run more than once...
				// in that case, don't worry, we'll choose the
				// right value in the "updates" section below!
				usePackageID[index] = packageID
			}
		}
	}

	// we can't determine which packages are "newest", without searching
	// for each one individually, so instead we check if any updates need
	// to be done, and if so, anything that needs updating isn't newest!
	// if something isn't installed, we can't verify it with this method
	// FIXME: https://github.com/hughsie/PackageKit/issues/116
	updates, err := obj.GetUpdates(filter)
	if err != nil {
		return nil, errwrap.Wrapf(err, "updates error")
	}
	for _, packageID := range updates {
		//obj.Logf("* %v", packageID)
		// format is: name;version;arch;data
		s := strings.Split(packageID, ";")
		//if len(s) != 4 { continue } // this would be a bug!
		pkg, _, _, _ := s[0], s[1], s[2], s[3]
		for index := range packages { // find pkg if it exists
			if pkg == packages[index] {
				state := packageMap[pkg] // lookup
				newest[index] = false
				if state == "installed" || state == "newest" {
					// fix up in case above wasn't correct!
					usePackageID[index] = packageID
				}
				break
			}
		}
	}

	// skip if the "newest" filter was used, otherwise we might need fixing
	// this check is for packages that need to verify their "newest" status
	// we need to know this so we can install the correct newest packageID!
	recursion := make(map[string]*PkPackageIDActionData)
	if !(filter&PkFilterEnumNewest == PkFilterEnumNewest) {
		checkPackages := []string{}
		filteredPackageMap := make(map[string]string)
		for index, pkg := range packages {
			state := packageMap[pkg]               // lookup the requested state/version
			if !found[index] || installed[index] { // skip these, they're okay
				continue
			}
			if !(state == "newest" || state == "installed") {
				continue
			}

			checkPackages = append(checkPackages, pkg)
			filteredPackageMap[pkg] = packageMap[pkg] // check me!
		}

		// we _could_ do a second resolve and then parse like this...
		//resolved, e := obj.ResolvePackages(..., filter+PkFilterEnumNewest)
		// but that's basically what recursion here could do too!
		if len(checkPackages) > 0 {
			if obj.Debug {
				obj.Logf("PackagesToPackageIDs(): Recurse: %s", strings.Join(checkPackages, ", "))
			}
			recursion, err = obj.PackagesToPackageIDs(ctx, filteredPackageMap, filter+PkFilterEnumNewest)
			if err != nil {
				return nil, errwrap.Wrapf(err, "recursion error")
			}
		}
	}

	// fix up and build result format
	result := make(map[string]*PkPackageIDActionData)
	for index, pkg := range packages {

		if !found[index] || !installed[index] {
			newest[index] = false // make the results more logical!
		}

		// prefer recursion results if present
		if lookup, ok := recursion[pkg]; ok {
			result[pkg] = lookup
		} else {
			result[pkg] = &PkPackageIDActionData{
				Found:     found[index],
				Installed: installed[index],
				Version:   version[index],
				PackageID: usePackageID[index],
				Newest:    newest[index],
			}
		}
	}

	return result, nil
}

// FilterPackageIDs returns a list of packageIDs which match the set of package
// names in packages.
func FilterPackageIDs(m map[string]*PkPackageIDActionData, packages []string) ([]string, error) {
	result := []string{}
	for _, k := range packages {
		p, ok := m[k] // lookup single package
		// package doesn't exist, this is an error!
		if !ok || !p.Found || p.PackageID == "" {
			return nil, fmt.Errorf("can't find package named '%s'", k)
		}
		result = append(result, p.PackageID)
	}
	return result, nil
}

// FilterState returns a map of whether each package queried matches the
// particular state.
func FilterState(m map[string]*PkPackageIDActionData, packages []string, state string) (result map[string]bool, err error) {
	result = make(map[string]bool)
	pkgs := []string{} // bad pkgs that don't have a bool state
	for _, k := range packages {
		p, ok := m[k] // lookup single package
		// package doesn't exist, this is an error!
		if !ok || !p.Found {
			return nil, fmt.Errorf("can't find package named '%s'", k)
		}
		var b bool
		if state == "installed" {
			b = p.Installed
		} else if state == "uninstalled" {
			b = !p.Installed
		} else if state == "newest" {
			b = p.Newest
		} else if state != "" {
			// treat any non-empty unknown state as a version pin
			b = state == p.Version
		} else {
			pkgs = append(pkgs, k)
			continue
		}
		result[k] = b // save
	}
	if len(pkgs) > 0 {
		err = fmt.Errorf("can't filter non-boolean state on: %s", strings.Join(pkgs, ","))
	}
	return result, err
}

// FilterPackageState returns all packages that are in package and match the
// specific state.
func FilterPackageState(m map[string]*PkPackageIDActionData, packages []string, state string) (result []string, err error) {
	result = []string{}
	for _, k := range packages {
		p, ok := m[k] // lookup single package
		// package doesn't exist, this is an error!
		if !ok || !p.Found {
			return nil, fmt.Errorf("can't find package named '%s'", k)
		}
		b := false
		if state == "installed" && p.Installed {
			b = true
		} else if state == "uninstalled" && !p.Installed {
			b = true
		} else if state == "newest" && p.Newest {
			b = true
		} else if state == p.Version {
			b = true
		}
		if b {
			result = append(result, k)
		}
	}
	return result, err
}

// FlagInData asks whether a flag exists inside the data portion of a packageID
// field?
func FlagInData(flag, data string) bool {
	flags := strings.Split(data, ":")
	for _, f := range flags {
		if f == flag {
			return true
		}
	}
	return false
}

// FmtTransactionMethod builds the transaction method string properly.
func FmtTransactionMethod(method string) string {
	return fmt.Sprintf("%s.%s", PkIfaceTransaction, method)
}

// IsMyArch determines if a PackageKit architecture matches the current os arch.
func IsMyArch(arch string) (bool, error) {
	goarch, ok := archUtil.MapPackageKitArchToGoArch[arch]
	if !ok {
		// if you get this error, please update the PkArchMap const
		return false, fmt.Errorf("arch '%s', not found", arch)
	}
	if goarch == archUtil.Any { // special value that corresponds to noarch
		return true, nil
	}
	return goarch == runtime.GOARCH, nil
}
