// 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 graph

import (
	"context"
	"fmt"
	"os"
	"path"
	"sort"

	"github.com/purpleidea/mgmt/engine"
	engineUtil "github.com/purpleidea/mgmt/engine/util"
	"github.com/purpleidea/mgmt/pgraph"
	"github.com/purpleidea/mgmt/util/errwrap"
)

const (
	// ReverseFile is the file name in the resource state dir where any
	// reversal information is stored.
	ReverseFile = "reverse"

	// ReversePerm is the permissions mode used to create the ReverseFile.
	ReversePerm = 0600
)

// Reversals adds the reversals onto the loaded graph. This should happen last,
// and before Commit.
// TODO: use the ctx? maybe one day wrap the file i/o stuff
func (obj *Engine) Reversals(ctx context.Context) error {
	if obj.nextGraph == nil {
		return fmt.Errorf("there is no active graph to add reversals to")
	}

	// Initially get all of the reversals to seek out all possible errors.
	// XXX: The engine needs to know where data might have been stored if we
	// XXX: want to potentially allow alternate read/write paths, like etcd.
	// XXX: In this scenario, we'd have to store a token somewhere to let us
	// XXX: know to look elsewhere for the special ReversalList read method.
	data, err := obj.ReversalList() // (map[string]string, error)
	if err != nil {
		return errwrap.Wrapf(err, "the reversals had errors")
	}

	if len(data) == 0 {
		return nil // end early
	}

	resMatch := func(r1, r2 engine.Res) bool { // simple match on UID only!
		if r1.Kind() != r2.Kind() {
			return false
		}
		if r1.Name() != r2.Name() {
			return false
		}
		return true
	}
	resInList := func(needle engine.Res, haystack []engine.Res) bool {
		for _, res := range haystack {
			if resMatch(needle, res) {
				return true
			}
		}
		return false
	}

	if obj.Debug {
		obj.Logf("decoding %d reversals...", len(data))
	}
	resources := []engine.Res{}

	// do this in a sorted order so that it errors deterministically
	sorted := []string{}
	for key := range data {
		sorted = append(sorted, key)
	}
	sort.Strings(sorted)
	for _, key := range sorted {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}

		val := data[key]
		// XXX: replace this ResToB64 method with one that stores it in
		// a human readable format, in case someone wants to hack and
		// edit it manually.
		// XXX: we probably want this to be YAML, it works with the diff
		// too...
		r, err := engineUtil.B64ToRes(val)
		if err != nil {
			return errwrap.Wrapf(err, "error decoding res with UID: `%s`", key)
		}

		res, ok := r.(engine.ReversibleRes)
		if !ok {
			// this requirement is here to keep things simpler...
			return fmt.Errorf("decoded res with UID: `%s` was not reversible", key)
		}

		matchFn := func(vertex pgraph.Vertex) (bool, error) {
			r, ok := vertex.(engine.Res)
			if !ok {
				return false, fmt.Errorf("not a Res")
			}
			if !resMatch(r, res) {
				return false, nil
			}
			return true, nil
		}

		// FIXME: not efficient, we could build a cache-map first
		vertex, err := obj.nextGraph.VertexMatchFn(matchFn) // (Vertex, error)
		if err != nil {
			return errwrap.Wrapf(err, "error searching graph for match")
		}
		if vertex != nil { // found one!
			continue // it doesn't need reversing yet
		}

		// TODO: check for (incompatible?) duplicates instead
		if resInList(res, resources) { // we've already got this one...
			continue
		}

		// We set this in two different places to be safe. It ensures
		// that we erase the reversal state file after we've used it.
		res.ReversibleMeta().Reversal = true // set this for later...

		resources = append(resources, res)
	}

	if len(resources) == 0 {
		return nil // end early
	}
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	// Now that we've passed the chance of any errors, we modify the graph.
	obj.Logf("adding %d reversals...", len(resources))
	for _, res := range resources {
		obj.nextGraph.AddVertex(res)
	}
	// TODO: Do we want a way for stored reversals to add edges too?

	// It would be great to ensure we didn't add any graph cycles here, but
	// instead of checking now, we'll move the check into the main loop.
	return nil
}

// ReversalList returns all the available pending reversal data on this host. It
// can then be decoded by whatever method is appropriate for.
func (obj *Engine) ReversalList() (map[string]string, error) {
	result := make(map[string]string) // some key to contents

	dir := obj.statePrefix() // loop through this dir...
	files, err := os.ReadDir(dir)
	if err != nil && !os.IsNotExist(err) {
		return nil, errwrap.Wrapf(err, "error reading list of state dirs")
	} else if err != nil {
		return result, nil // nothing found, no state dir exists yet
	}

	for _, x := range files {
		key := x.Name() // some uid for the resource
		file := path.Join(dir, key, ReverseFile)
		content, err := os.ReadFile(file)
		if err != nil && !os.IsNotExist(err) {
			return nil, errwrap.Wrapf(err, "could not read reverse file: %s", file)
		} else if err != nil {
			continue // file does not exist, skip
		}

		// file exists!
		str := string(content)
		result[key] = str // save
	}

	return result, nil
}

// ReversalInit performs the reversal initialization steps if necessary for this
// resource.
func (obj *State) ReversalInit() error {
	res, ok := obj.Vertex.(engine.ReversibleRes)
	if !ok {
		return nil // nothing to do
	}

	if res.ReversibleMeta().Disabled {
		return nil // nothing to do, reversal isn't enabled
	}

	// If the reversal is enabled, but we are the result of a previous
	// reversal, then this will overwrite that older reversal request, and
	// our resource should be designed to deal with that. This happens if we
	// return a reversible resource as the reverse of a resource that was
	// reversed. It's probably fairly rare.
	if res.ReversibleMeta().Reversal {
		obj.Logf("triangle reversal") // warn!
	}

	r, err := res.Reversed()
	if err != nil {
		return errwrap.Wrapf(err, "could not reverse: %s", res.String())
	}
	if r == nil {
		return nil // this can't be reversed, or isn't implemented here
	}

	// We set this in two different places to be safe. It ensures that we
	// erase the reversal state file after we've used it.
	r.ReversibleMeta().Reversal = true // set this for later...

	// XXX: replace this ResToB64 method with one that stores it in a human
	// readable format, in case someone wants to hack and edit it manually.
	// XXX: we probably want this to be YAML, it works with the diff too...
	str, err := engineUtil.ResToB64(r)
	if err != nil {
		return errwrap.Wrapf(err, "could not encode: %s", res.String())
	}

	// TODO: put this method on traits.Reversible as part of the interface?
	return obj.ReversalWrite(str, res.ReversibleMeta().Overwrite) // Store!
}

// ReversalCleanup performs the reversal shutdown steps if necessary for this
// resource.
func (obj *State) ReversalCleanup() error {
	res, ok := obj.Vertex.(engine.ReversibleRes)
	if !ok {
		return nil // nothing to do
	}

	// Don't check res.ReversibleMeta().Disabled because we're removing the
	// previous one. That value only applies if we're doing a new reversal.

	if !res.ReversibleMeta().Reversal {
		return nil // nothing to erase, we're not a reversal resource
	}

	if !obj.isStateOK.Load() { // did we successfully reverse? (mutex RLock/RUnlock)
		obj.Logf("did not complete reversal") // warn
		return nil
	}

	// TODO: put this method on traits.Reversible as part of the interface?
	return obj.ReversalDelete() // Erase our reversal instructions.
}

// ReversalWrite stores the reversal state information for this resource.
func (obj *State) ReversalWrite(str string, overwrite bool) error {
	dir, err := obj.varDir("") // private version
	if err != nil {
		return errwrap.Wrapf(err, "could not get VarDir for reverse")
	}
	file := path.Join(dir, ReverseFile) // return a unique file

	content, err := os.ReadFile(file)
	if err != nil && !os.IsNotExist(err) {
		return errwrap.Wrapf(err, "could not read reverse file: %s", file)
	}

	// file exists and we shouldn't overwrite if different
	if err == nil && !overwrite {
		// compare to existing file
		oldStr := string(content)
		if str != oldStr {
			obj.Logf("existing, pending, reversible resource exists")
			//obj.Logf("diff:")
			//obj.Logf("") // TODO: print the diff w/o secret values
			return fmt.Errorf("existing, pending, reversible resource exists")
		}
	}

	return os.WriteFile(file, []byte(str), ReversePerm)
}

// ReversalDelete removes the reversal state information for this resource.
func (obj *State) ReversalDelete() error {
	dir, err := obj.varDir("") // private version
	if err != nil {
		return errwrap.Wrapf(err, "could not get VarDir for reverse")
	}
	file := path.Join(dir, ReverseFile) // return a unique file

	// FIXME: why do we see these removals when there isn't a state file?
	if err = os.Remove(file); os.IsNotExist(err) {
		return nil // ignore missing files
	}

	return errwrap.Wrapf(err, "could not remove reverse state file")
}
