// Mgmt
// Copyright (C) 2013-2024+ 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 resources

import (
	"context"
	"fmt"
	"path"
	"strings"

	"github.com/purpleidea/mgmt/engine"
	"github.com/purpleidea/mgmt/engine/resources/packagekit"
	"github.com/purpleidea/mgmt/engine/traits"
	"github.com/purpleidea/mgmt/util"
	"github.com/purpleidea/mgmt/util/errwrap"
)

func init() {
	engine.RegisterResource("pkg", func() engine.Res { return &PkgRes{} })
}

const (
	// PkgStateInstalled is the string that represents that the package
	// should be installed.
	PkgStateInstalled = "installed"

	// PkgStateUninstalled is the string that represents that the package
	// should be uninstalled.
	PkgStateUninstalled = "uninstalled"

	// PkgStateNewest is the string that represents that the package should
	// be installed in the newest available version.
	PkgStateNewest = "newest"
)

// PkgRes is a package resource for packagekit.
type PkgRes struct {
	traits.Base // add the base methods without re-implementation
	traits.Edgeable
	traits.Groupable

	init *engine.Init

	// State determines if we want to install or uninstall the package, and
	// what version we want to pin if any. Valid values include: installed,
	// uninstalled, newest, and `version`, where you just put the raw
	// version string desired.
	State string `lang:"state" yaml:"state"`

	// AllowUntrusted specifies if we want to allow untrusted packages to be
	// installed. Please see the PackageKit documentation for more
	// information.
	AllowUntrusted bool `lang:"allowuntrusted" yaml:"allowuntrusted"`

	// AllowNonFree specifies if we want to allow nonfree packages to be
	// found? Please see the PackageKit documentation for more information.
	AllowNonFree bool `lang:"allownonfree" yaml:"allownonfree"`

	// AllowUnsupported specifies if we want to unsupported packages to be
	// found? Please see the PackageKit documentation for more information.
	AllowUnsupported bool `lang:"allowunsupported" yaml:"allowunsupported"`

	//bus              *packagekit.Conn    // pk bus connection
	fileList []string // FIXME: update if pkg changes
}

// Default returns some sensible defaults for this resource.
func (obj *PkgRes) Default() engine.Res {
	return &PkgRes{
		State: PkgStateInstalled, // this *is* preferable to "newest"
	}
}

// Validate checks if the resource data structure was populated correctly.
func (obj *PkgRes) Validate() error {
	if obj.State == "" {
		return fmt.Errorf("state cannot be empty")
	}
	if obj.State == "latest" {
		return fmt.Errorf("state is invalid, did you mean `newest` ?")
	}

	return nil
}

// Init runs some startup code for this resource.
func (obj *PkgRes) Init(init *engine.Init) error {
	obj.init = init // save for later

	if obj.fileList == nil {
		if err := obj.populateFileList(); err != nil {
			return errwrap.Wrapf(err, "error populating file list in init")
		}
	}

	return nil
}

// Cleanup is run by the engine to clean up after the resource is done.
func (obj *PkgRes) Cleanup() error {
	return nil
}

// Watch is the primary listener for this resource and it outputs events. It
// uses the PackageKit UpdatesChanged signal to watch for changes.
// TODO: https://github.com/hughsie/PackageKit/issues/109
// TODO: https://github.com/hughsie/PackageKit/issues/110
func (obj *PkgRes) Watch(ctx context.Context) error {
	bus := packagekit.NewBus()
	if bus == nil {
		return fmt.Errorf("can't connect to PackageKit bus")
	}
	defer bus.Close()
	bus.Debug = obj.init.Debug
	bus.Logf = func(format string, v ...interface{}) {
		obj.init.Logf("packagekit: "+format, v...)
	}

	ch, err := bus.WatchChanges()
	if err != nil {
		return errwrap.Wrapf(err, "error adding signal match")
	}

	obj.init.Running() // when started, notify engine that we're running

	var send = false // send event?
	for {
		if obj.init.Debug {
			obj.init.Logf("%s: Watching...", obj.fmtNames(obj.getNames()))
		}

		select {
		case event := <-ch:
			// FIXME: ask packagekit for info on what packages changed
			if obj.init.Debug {
				obj.init.Logf("Event(%s): %s", event.Name, obj.fmtNames(obj.getNames()))
			}

			// since the chan is buffered, remove any supplemental
			// events since they would just be duplicates anyways!
			for len(ch) > 0 { // we can detect pending count here!
				<-ch // discard
			}

			send = true

		case <-ctx.Done(): // closed by the engine to signal shutdown
			return nil
		}

		// do all our event sending all together to avoid duplicate msgs
		if send {
			send = false
			obj.init.Event() // notify engine of an event (this can block)
		}
	}
}

// get list of names when grouped or not
func (obj *PkgRes) getNames() []string {
	if g := obj.GetGroup(); len(g) > 0 { // grouped elements
		names := []string{obj.Name()}
		for _, x := range g {
			pkg, ok := x.(*PkgRes) // convert from Res
			if ok {
				names = append(names, pkg.Name())
			}
		}
		return names
	}
	return []string{obj.Name()}
}

// pretty print for header values
func (obj *PkgRes) fmtNames(names []string) string {
	if len(obj.GetGroup()) > 0 { // grouped elements
		return fmt.Sprintf("%s[autogroup:(%s)]", obj.Kind(), strings.Join(names, ","))
	}
	return obj.String()
}

func (obj *PkgRes) groupMappingHelper() map[string]string {
	var result = make(map[string]string)
	if g := obj.GetGroup(); len(g) > 0 { // add any grouped elements
		for _, x := range g {
			pkg, ok := x.(*PkgRes) // convert from Res
			if !ok {
				panic(fmt.Sprintf("grouped member %v is not a %s", x, obj.Kind()))
			}
			result[pkg.Name()] = pkg.State
		}
	}
	return result
}

func (obj *PkgRes) pkgMappingHelper(bus *packagekit.Conn) (map[string]*packagekit.PkPackageIDActionData, error) {
	packageMap := obj.groupMappingHelper() // get the grouped values
	packageMap[obj.Name()] = obj.State     // key is pkg name, value is pkg state
	var filter uint64                      // initializes at the "zero" value of 0
	filter += packagekit.PkFilterEnumArch  // always search in our arch (optional!)
	// we're requesting newest version, or to narrow down install choices!
	if obj.State == PkgStateNewest || obj.State == PkgStateInstalled {
		// if we add this, we'll still see older packages if installed
		// this is an optimization, and is *optional*, this logic is
		// handled inside of PackagesToPackageIDs now automatically!
		filter += packagekit.PkFilterEnumNewest // only search for newest packages
	}
	if !obj.AllowNonFree {
		filter += packagekit.PkFilterEnumFree
	}
	if !obj.AllowUnsupported {
		filter += packagekit.PkFilterEnumSupported
	}
	result, err := bus.PackagesToPackageIDs(packageMap, filter)
	if err != nil {
		return nil, errwrap.Wrapf(err, "can't run PackagesToPackageIDs")
	}
	return result, nil
}

// populateFileList fills in the fileList structure with what is in the package.
// TODO: should this work properly if pkg has been autogrouped ?
func (obj *PkgRes) populateFileList() error {

	bus := packagekit.NewBus()
	if bus == nil {
		return fmt.Errorf("can't connect to PackageKit bus")
	}
	defer bus.Close()
	if obj.init != nil {
		bus.Debug = obj.init.Debug
		bus.Logf = func(format string, v ...interface{}) {
			obj.init.Logf("packagekit: "+format, v...)
		}
	}

	result, err := obj.pkgMappingHelper(bus)
	if err != nil {
		return errwrap.Wrapf(err, "the pkgMappingHelper failed")
	}

	data, ok := result[obj.Name()] // lookup single package (init does just one)
	// package doesn't exist, this is an error!
	if !ok || !data.Found {
		return fmt.Errorf("can't find package named '%s'", obj.Name())
	}
	if data.PackageID == "" {
		// this can happen if you specify a bad version like "latest"
		return fmt.Errorf("empty PackageID found for '%s'", obj.Name())
	}

	packageIDs := []string{data.PackageID} // just one for now
	filesMap, err := bus.GetFilesByPackageID(packageIDs)
	if err != nil {
		return errwrap.Wrapf(err, "can't run GetFilesByPackageID")
	}
	if files, ok := filesMap[data.PackageID]; ok {
		obj.fileList = util.DirifyFileList(files, false)
	}

	return nil
}

// CheckApply checks the resource state and applies the resource if the bool
// input is true. It returns error info and if the state check passed or not.
func (obj *PkgRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
	obj.init.Logf("Check: %s", obj.fmtNames(obj.getNames()))

	bus := packagekit.NewBus()
	if bus == nil {
		return false, fmt.Errorf("can't connect to PackageKit bus")
	}
	defer bus.Close()
	bus.Debug = obj.init.Debug
	bus.Logf = func(format string, v ...interface{}) {
		obj.init.Logf("packagekit: "+format, v...)
	}

	result, err := obj.pkgMappingHelper(bus)
	if err != nil {
		return false, errwrap.Wrapf(err, "the pkgMappingHelper failed")
	}

	packageMap := obj.groupMappingHelper() // map[string]string
	packageList := []string{obj.Name()}
	packageList = append(packageList, util.StrMapKeys(packageMap)...)
	//stateList := []string{obj.State}
	//stateList = append(stateList, util.StrMapValues(packageMap)...)

	// TODO: at the moment, all the states are the same, but
	// eventually we might be able to drop this constraint!
	states, err := packagekit.FilterState(result, packageList, obj.State)
	if err != nil {
		return false, errwrap.Wrapf(err, "the FilterState method failed")
	}
	data, _ := result[obj.Name()] // if above didn't error, we won't either!
	validState := util.BoolMapTrue(util.BoolMapValues(states))

	// obj.State == PkgStateInstalled || PkgStateUninstalled || PkgStateNewest || "4.2-1.fc23"
	switch obj.State {
	case PkgStateInstalled:
		fallthrough
	case PkgStateUninstalled:
		fallthrough
	case PkgStateNewest:
		if validState {
			return true, nil // state is correct, exit!
		}
	default: // version string
		if obj.State == data.Version && data.Version != "" {
			return true, nil
		}
	}

	// state is not okay, no work done, exit, but without error
	if !apply {
		return false, nil
	}

	// apply portion
	obj.init.Logf("Apply: %s", obj.fmtNames(obj.getNames()))
	readyPackages, err := packagekit.FilterPackageState(result, packageList, obj.State)
	if err != nil {
		return false, err // fail
	}
	// these are the packages that actually need their states applied!
	applyPackages := util.StrFilterElementsInList(readyPackages, packageList)
	packageIDs, _ := packagekit.FilterPackageIDs(result, applyPackages) // would be same err as above

	var transactionFlags uint64 // initializes at the "zero" value of 0
	if !obj.AllowUntrusted {    // allow
		transactionFlags += packagekit.PkTransactionFlagEnumOnlyTrusted
	}
	// apply correct state!
	obj.init.Logf("Set(%s): %s...", obj.State, obj.fmtNames(util.StrListIntersection(applyPackages, obj.getNames())))
	switch obj.State {
	case PkgStateUninstalled: // run remove
		// NOTE: packageID is different than when installed, because now
		// it has the "installed" flag added to the data portion of it!!
		err = bus.RemovePackages(packageIDs, transactionFlags)

	case PkgStateNewest: // TODO: isn't this the same operation as install, below?
		err = bus.UpdatePackages(packageIDs, transactionFlags)

	case PkgStateInstalled:
		fallthrough // same method as for "set specific version", below
	default: // version string
		err = bus.InstallPackages(packageIDs, transactionFlags)
	}
	if err != nil {
		return false, err // fail
	}
	obj.init.Logf("Set(%s) success: %s", obj.State, obj.fmtNames(util.StrListIntersection(applyPackages, obj.getNames())))
	return false, nil // success
}

// Cmp compares two resources and returns an error if they are not equivalent.
func (obj *PkgRes) Cmp(r engine.Res) error {
	// we can only compare PkgRes to others of the same resource kind
	res, ok := r.(*PkgRes)
	if !ok {
		return fmt.Errorf("res is not the same kind")
	}

	if obj.State != res.State {
		return fmt.Errorf("state differs: %s vs %s", obj.State, res.State)
	}

	return obj.Adapts(res)
}

// Adapts compares two resources and returns an error if they are not able to be
// equivalently output compatible.
func (obj *PkgRes) Adapts(r engine.CompatibleRes) error {
	res, ok := r.(*PkgRes)
	if !ok {
		return fmt.Errorf("res is not the same kind")
	}

	if obj.State != res.State {
		e := fmt.Errorf("state differs in an incompatible way: %s vs %s", obj.State, res.State)
		if obj.State == PkgStateUninstalled || res.State == PkgStateUninstalled {
			return e
		}
		if stateIsVersion(obj.State) || stateIsVersion(res.State) {
			return e
		}
		// one must be installed, and the other must be "newest"
	}

	if obj.AllowUntrusted != res.AllowUntrusted {
		return fmt.Errorf("allowuntrusted differs: %t vs %t", obj.AllowUntrusted, res.AllowUntrusted)
	}
	if obj.AllowNonFree != res.AllowNonFree {
		return fmt.Errorf("allownonfree differs: %t vs %t", obj.AllowNonFree, res.AllowNonFree)
	}
	if obj.AllowUnsupported != res.AllowUnsupported {
		return fmt.Errorf("allowunsupported differs: %t vs %t", obj.AllowUnsupported, res.AllowUnsupported)
	}

	return nil
}

// Merge returns the best equivalent of the two resources. They must satisfy the
// Adapts test for this to work.
func (obj *PkgRes) Merge(r engine.CompatibleRes) (engine.CompatibleRes, error) {
	res, ok := r.(*PkgRes)
	if !ok {
		return nil, fmt.Errorf("res is not the same kind")
	}

	if err := obj.Adapts(r); err != nil {
		return nil, errwrap.Wrapf(err, "can't merge resources that aren't compatible")
	}

	// modify the copy, not the original
	x, err := engine.ResCopy(obj) // don't call our .Copy() directly!
	if err != nil {
		return nil, err
	}
	result, ok := x.(*PkgRes)
	if !ok {
		// bug!
		return nil, fmt.Errorf("res is not the same kind")
	}

	// if these two were compatible then if they're not identical, then one
	// must be PkgStateNewest and the other is PkgStateInstalled, so we
	// upgrade to the best common denominator
	if obj.State != res.State {
		result.State = PkgStateNewest
	}

	return result, nil
}

// Copy copies the resource. Don't call it directly, use engine.ResCopy instead.
// TODO: should this copy internal state?
func (obj *PkgRes) Copy() engine.CopyableRes {
	return &PkgRes{
		State:            obj.State,
		AllowUntrusted:   obj.AllowUntrusted,
		AllowNonFree:     obj.AllowNonFree,
		AllowUnsupported: obj.AllowUnsupported,
	}
}

// PkgUID is the main UID struct for PkgRes.
type PkgUID struct {
	engine.BaseUID
	name  string // pkg name
	state string // pkg state or "version"
}

// PkgFileUID is the UID struct for PkgRes files.
type PkgFileUID struct {
	engine.BaseUID
	path string // path of the file
}

// IFF aka if and only if they are equivalent, return true. If not, false.
func (obj *PkgUID) IFF(uid engine.ResUID) bool {
	res, ok := uid.(*PkgUID)
	if !ok {
		return false
	}
	// FIXME: match on obj.state vs. res.state ?
	return obj.name == res.name
}

// PkgResAutoEdges holds the state of the auto edge generator.
type PkgResAutoEdges struct {
	fileList   []string
	svcUIDs    []engine.ResUID
	testIsNext bool   // safety
	name       string // saved data from PkgRes obj
	kind       string
}

// Next returns the next automatic edge.
func (obj *PkgResAutoEdges) Next() []engine.ResUID {
	if obj.testIsNext {
		panic("expecting a call to Test()")
	}
	obj.testIsNext = true // set after all the errors paths are past

	// first return any matching svcUIDs
	if x := obj.svcUIDs; len(x) > 0 {
		return x
	}

	var result []engine.ResUID
	// return UID's for whatever is in obj.fileList
	for _, x := range obj.fileList {
		var reversed = false // cheat by passing a pointer
		result = append(result, &FileUID{
			BaseUID: engine.BaseUID{
				Name:     obj.name,
				Kind:     obj.kind,
				Reversed: &reversed,
			},
			path: x, // what matters
		}) // build list
	}
	return result
}

// Test gets results of the earlier Next() call, & returns if we should
// continue!
func (obj *PkgResAutoEdges) Test(input []bool) bool {
	if !obj.testIsNext {
		panic("expecting a call to Next()")
	}

	// ack the svcUID's...
	if x := obj.svcUIDs; len(x) > 0 {
		if y := len(x); y != len(input) {
			panic(fmt.Sprintf("expecting %d value(s)", y))
		}
		obj.svcUIDs = []engine.ResUID{} // empty
		obj.testIsNext = false
		return true
	}

	count := len(obj.fileList)
	if count != len(input) {
		panic(fmt.Sprintf("expecting %d value(s)", count))
	}
	obj.testIsNext = false // set after all the errors paths are past

	// while i do believe this algorithm generates the *correct* result, i
	// don't know if it does so in the optimal way. improvements welcome!
	// the basic logic is:
	// 0) Next() returns whatever is in fileList
	// 1) Test() computes the dirname of each file, and removes duplicates
	// and dirname's that have been in the path of an ack from input results
	// 2) It then simplifies the list by removing the common path prefixes
	// 3) Lastly, the remaining set of files (dirs) is used as new fileList
	// 4) We then iterate in (0) until the fileList is empty!
	var dirs = make([]string, count)
	done := []string{}
	for i := 0; i < count; i++ {
		dir := util.Dirname(obj.fileList[i]) // dirname of /foo/ should be /
		dirs[i] = dir
		if input[i] {
			done = append(done, dir)
		}
	}
	nodupes := util.StrRemoveDuplicatesInList(dirs)                // remove duplicates
	nodones := util.StrFilterElementsInList(done, nodupes)         // filter out done
	noempty := util.StrFilterElementsInList([]string{""}, nodones) // remove the "" from /
	obj.fileList = util.RemoveCommonFilePrefixes(noempty)          // magic

	if len(obj.fileList) == 0 { // nothing more, don't continue
		return false
	}
	return true // continue, there are more files!
}

// AutoEdges produces an object which generates a minimal pkg file optimization
// sequence of edges.
func (obj *PkgRes) AutoEdges() (engine.AutoEdge, error) {
	// in contrast with the FileRes AutoEdges() function which contains
	// more of the mechanics, most of the AutoEdge mechanics for the PkgRes
	// are contained in the Test() method! This design is completely okay!

	if obj.fileList == nil {
		if err := obj.populateFileList(); err != nil {
			return nil, errwrap.Wrapf(err, "error populating file list for automatic edges")
		}
	}

	// add matches for any svc resources found in pkg definition!
	var svcUIDs []engine.ResUID
	for _, x := range ReturnSvcInFileList(obj.fileList) {
		var reversed = false
		svcUIDs = append(svcUIDs, &SvcUID{
			BaseUID: engine.BaseUID{
				Name:     obj.Name(),
				Kind:     obj.Kind(),
				Reversed: &reversed,
			},
			name: x, // the svc name itself in the SvcUID object!
		}) // build list
	}

	return &PkgResAutoEdges{
		fileList:   util.RemoveCommonFilePrefixes(obj.fileList), // clean start!
		svcUIDs:    svcUIDs,
		testIsNext: false,      // start with Next() call
		name:       obj.Name(), // save data for PkgResAutoEdges obj
		kind:       obj.Kind(),
	}, nil
}

// UIDs includes all params to make a unique identification of this object. Most
// resources only return one, although some resources can return multiple.
func (obj *PkgRes) UIDs() []engine.ResUID {
	x := &PkgUID{
		BaseUID: engine.BaseUID{Name: obj.Name(), Kind: obj.Kind()},
		name:    obj.Name(),
		state:   obj.State,
	}
	result := []engine.ResUID{x}

	for _, y := range obj.fileList {
		y := &PkgFileUID{
			BaseUID: engine.BaseUID{Name: obj.Name(), Kind: obj.Kind()},
			path:    y,
		}
		result = append(result, y)
	}
	return result
}

// GroupCmp returns whether two resources can be grouped together or not. Can
// these two resources be merged, aka, does this resource support doing so? Will
// resource allow itself to be grouped _into_ this obj?
func (obj *PkgRes) GroupCmp(r engine.GroupableRes) error {
	res, ok := r.(*PkgRes)
	if !ok {
		return fmt.Errorf("resource is not the same kind")
	}
	// TODO: what should we do about the empty string?
	if stateIsVersion(obj.State) || stateIsVersion(res.State) {
		// can't merge specific version checks atm
		return fmt.Errorf("resource uses a version string")
	}
	// FIXME: keep it simple for now, only merge same states
	if obj.State != res.State {
		return fmt.Errorf("resource is of a different state")
	}
	return nil
}

// UnmarshalYAML is the custom unmarshal handler for this struct. It is
// primarily useful for setting the defaults.
func (obj *PkgRes) UnmarshalYAML(unmarshal func(interface{}) error) error {
	type rawRes PkgRes // indirection to avoid infinite recursion

	def := obj.Default()     // get the default
	res, ok := def.(*PkgRes) // put in the right format
	if !ok {
		return fmt.Errorf("could not convert to PkgRes")
	}
	raw := rawRes(*res) // convert; the defaults go here

	if err := unmarshal(&raw); err != nil {
		return err
	}

	*obj = PkgRes(raw) // restore from indirection with type conversion!
	return nil
}

// ReturnSvcInFileList returns a list of svc names for matches like:
// `/usr/lib/systemd/system/*.service`.
func ReturnSvcInFileList(fileList []string) []string {
	result := []string{}
	for _, x := range fileList {
		dirname, basename := path.Split(path.Clean(x))
		// TODO: do we also want to look for /etc/systemd/system/ ?
		if dirname != "/usr/lib/systemd/system/" {
			continue
		}
		if !strings.HasSuffix(basename, ".service") {
			continue
		}
		if s := strings.TrimSuffix(basename, ".service"); !util.StrInList(s, result) {
			result = append(result, s)
		}
	}
	return result
}

// stateIsVersion is a simple test to see if the state string is an existing
// well-known flag.
// TODO: what should we do about the empty string?
func stateIsVersion(state string) bool {
	return (state != PkgStateInstalled && state != PkgStateUninstalled && state != PkgStateNewest) // must be a ver. string
}

// InstallOnePackage is a helper function which uses the minimum resource
// mechanics to install a single package. Keep this up-to-date with the resource
// internals since we may make some concessions in this helper function in that
// we don't call the full API.
func InstallOnePackage(ctx context.Context, name string) error {
	res, err := engine.NewNamedResource("pkg", name)
	if err != nil {
		return err
	}
	pkg, ok := res.(*PkgRes)
	if !ok {
		return fmt.Errorf("error casting PkgRes")
	}
	pkg.State = PkgStateInstalled

	init := &engine.Init{
		Debug: false,
		Logf: func(format string, v ...interface{}) {
			// noop
		},
	}
	if err := res.Init(init); err != nil {
		return err
	}

	apply := true
	if _, err := res.CheckApply(ctx, apply); err != nil {
		return err
	}

	return res.Cleanup()
}
