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

import (
	"context"
	"fmt"
	"strconv"
	"strings"
	"sync"

	"github.com/purpleidea/mgmt/etcd/interfaces"
	"github.com/purpleidea/mgmt/util/errwrap"

	etcd "go.etcd.io/etcd/client/v3"
	etcdUtil "go.etcd.io/etcd/client/v3/clientv3util"
)

const (
	deployPath  = "deploy"
	payloadPath = "payload"
	hashPath    = "hash"
)

// SimpleDeploy is a deploy struct that provides all of the needed deploy
// methods. It requires that you give it a Client interface so that it can
// perform its remote work. You must call Init before you use it, and Close when
// you are done.
type SimpleDeploy struct {
	Client interfaces.Client

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

	ns string // TODO: if we ever need to hardcode a base path
	wg *sync.WaitGroup
}

// Init validates the deploy structure and prepares it for first use.
func (obj *SimpleDeploy) Init() error {
	if obj.Client == nil {
		return fmt.Errorf("the Client was not specified")
	}
	obj.wg = &sync.WaitGroup{}
	return nil
}

// Close cleans up after using the deploy struct and waits for any ongoing
// watches to exit before it returns.
func (obj *SimpleDeploy) Close() error {
	obj.wg.Wait()
	return nil
}

// WatchDeploy returns a channel which spits out events on new deploy activity.
// It closes the channel when it's done, and spits out errors when something
// goes wrong. If it can't start up, it errors immediately. The returned channel
// is buffered, so that a quick succession of events will get discarded.
func (obj *SimpleDeploy) WatchDeploy(ctx context.Context) (chan error, error) {
	// key structure is $NS/deploy/$id/payload = $data
	path := fmt.Sprintf("%s/%s/", obj.ns, deployPath)
	// FIXME: obj.wg.Add(1) && obj.wg.Done()
	return obj.Client.Watcher(ctx, path, etcd.WithPrefix())
}

// GetDeploys gets all the available deploys.
func (obj *SimpleDeploy) GetDeploys(ctx context.Context) (map[uint64]string, error) {
	// key structure is $NS/deploy/$id/payload = $data
	path := fmt.Sprintf("%s/%s/", obj.ns, deployPath)
	keyMap, err := obj.Client.Get(ctx, path, etcd.WithPrefix(), etcd.WithSort(etcd.SortByKey, etcd.SortAscend))
	if err != nil {
		return nil, errwrap.Wrapf(err, "could not get deploy")
	}
	result := make(map[uint64]string)
	for key, val := range keyMap {
		if !strings.HasPrefix(key, path) { // sanity check
			continue
		}

		str := strings.Split(key[len(path):], "/")
		if len(str) != 2 {
			return nil, fmt.Errorf("unexpected chunk count of %d", len(str))
		}
		if s := str[1]; s != payloadPath {
			continue // skip, maybe there are other future additions
		}

		var id uint64
		var err error
		x := str[0]
		if id, err = strconv.ParseUint(x, 10, 64); err != nil {
			return nil, fmt.Errorf("invalid id of `%s`", x)
		}

		// TODO: do some sort of filtering here?
		//obj.Logf("GetDeploys(%s): Id => Data: %d => %s", key, id, val)
		result[id] = val
	}
	return result, nil
}

// calculateMax is a helper function.
func calculateMax(deploys map[uint64]string) uint64 {
	var max uint64
	for i := range deploys {
		if i > max {
			max = i
		}
	}
	return max
}

// GetDeploy returns the deploy with the specified id if it exists. If you input
// an id of 0, you'll get back an empty deploy without error. This is useful so
// that you can pass through this function easily.
// FIXME: implement this more efficiently so that it doesn't have to download
// *all* the old deploys from etcd!
func (obj *SimpleDeploy) GetDeploy(ctx context.Context, id uint64) (string, error) {
	result, err := obj.GetDeploys(ctx)
	if err != nil {
		return "", err
	}

	// don't optimize this test to the top, because it's better to catch an
	// etcd failure early if we can, rather than fail later when we deploy!
	if id == 0 {
		return "", nil // no results yet
	}

	str, exists := result[id]
	if !exists {
		return "", fmt.Errorf("can't find id `%d`", id)
	}
	return str, nil
}

// GetMaxDeployID returns the maximum deploy id. If none are found, this returns
// zero. You must increment the returned value by one when you add a deploy. If
// two or more clients race for this deploy id, then the loser is not committed,
// and must repeat this GetMaxDeployID process until it succeeds with a commit!
func (obj *SimpleDeploy) GetMaxDeployID(ctx context.Context) (uint64, error) {
	// TODO: this was all implemented super inefficiently, fix up for perf!
	deploys, err := obj.GetDeploys(ctx) // get previous deploys
	if err != nil {
		return 0, errwrap.Wrapf(err, "error getting previous deploys")
	}
	// find the latest id
	max := calculateMax(deploys)
	return max, nil // found! (or zero)
}

// AddDeploy adds a new deploy. It takes an id and ensures it's sequential. If
// hash is not empty, then it will check that the pHash matches what the
// previous hash was, and also adds this new hash along side the id. This is
// useful to make sure you get a linear chain of git patches, and to avoid two
// contributors pushing conflicting deploys. This isn't git specific, and so any
// arbitrary string hash can be used.
// FIXME: prune old deploys from the store when they aren't needed anymore...
func (obj *SimpleDeploy) AddDeploy(ctx context.Context, id uint64, hash, pHash string, data *string) error {
	// key structure is $NS/deploy/$id/payload = $data
	// key structure is $NS/deploy/$id/hash = $hash
	path := fmt.Sprintf("%s/%s/%d/%s", obj.ns, deployPath, id, payloadPath)
	tPath := fmt.Sprintf("%s/%s/%d/%s", obj.ns, deployPath, id, hashPath)
	ifs := []etcd.Cmp{} // list matching the desired state
	ops := []etcd.Op{}  // list of ops in this transaction (then)

	// we're append only, so ensure this unique deploy id doesn't exist
	//ifs = append(ifs, etcd.Compare(etcd.Version(path), "=", 0)) // KeyMissing
	ifs = append(ifs, etcdUtil.KeyMissing(path))

	// don't look for previous deploy if this is the first deploy ever
	if id > 1 {
		// we append sequentially, so ensure previous key *does* exist
		prev := fmt.Sprintf("%s/%s/%d/%s", obj.ns, deployPath, id-1, payloadPath)
		//ifs = append(ifs, etcd.Compare(etcd.Version(prev), ">", 0)) // KeyExists
		ifs = append(ifs, etcdUtil.KeyExists(prev))

		if hash != "" && pHash != "" {
			// does the previously stored hash match what we expect?
			prevHash := fmt.Sprintf("%s/%s/%d/%s", obj.ns, deployPath, id-1, hashPath)
			ifs = append(ifs, etcd.Compare(etcd.Value(prevHash), "=", pHash))
		}
	}

	ops = append(ops, etcd.OpPut(path, *data))
	if hash != "" {
		ops = append(ops, etcd.OpPut(tPath, hash)) // store new hash as well
	}

	// it's important to do this in one transaction, and atomically, because
	// this way, we only generate one watch event, and only when it's needed
	result, err := obj.Client.Txn(ctx, ifs, ops, nil)
	if err != nil {
		return errwrap.Wrapf(err, "error creating deploy id %d", id)
	}
	if !result.Succeeded {
		return obj.explainFailedDeployTxn(ctx, id, hash, pHash, path)
	}
	return nil // success
}

// explainFailedDeployTxn tries to return a better error message by running a
// new query. Ideally we'd get this information from the transaction, but the
// etcd API has many limitations that prevent being about to do this easily.
func (obj *SimpleDeploy) explainFailedDeployTxn(ctx context.Context, id uint64, hash, pHash, path string) error {
	if _, exists, err := obj.getDeployValue(ctx, path); err != nil {
		return errwrap.Wrapf(err, "could not inspect deploy id %d", id)
	} else if exists {
		if hash != "" {
			tHash := fmt.Sprintf("%s/%s/%d/%s", obj.ns, deployPath, id, hashPath)
			if value, exists, err := obj.getDeployValue(ctx, tHash); err != nil {
				return errwrap.Wrapf(err, "could not inspect deploy hash for deploy id %d", id)
			} else if exists && value == hash {
				return fmt.Errorf("deploy hash is unchanged at %q; nothing new to deploy", hash)
			}
		}
		return fmt.Errorf("deploy id %d already exists; another deploy was created first, try again", id)
	}

	if id > 1 {
		prev := fmt.Sprintf("%s/%s/%d/%s", obj.ns, deployPath, id-1, payloadPath)
		if _, exists, err := obj.getDeployValue(ctx, prev); err != nil {
			return errwrap.Wrapf(err, "could not inspect previous deploy id %d", id-1)
		} else if !exists {
			return fmt.Errorf("previous deploy id %d does not exist; deploy ids must be created sequentially", id-1)
		}

		if hash != "" && pHash != "" {
			prevHash := fmt.Sprintf("%s/%s/%d/%s", obj.ns, deployPath, id-1, hashPath)
			value, exists, err := obj.getDeployValue(ctx, prevHash)
			if err != nil {
				return errwrap.Wrapf(err, "could not inspect previous deploy hash for deploy id %d", id-1)
			}
			if !exists {
				return fmt.Errorf("previous deploy hash for deploy id %d is missing; expected %q", id-1, pHash)
			}
			if value == hash {
				return fmt.Errorf("deploy hash is unchanged at %q; nothing new to deploy", hash)
			}
			if value != pHash {
				return fmt.Errorf("previous deploy hash mismatch for deploy id %d; local commit does not have the expected parent from the latest deployed hash of: %q", id-1, value)
			}
		}
	}

	return fmt.Errorf("etcd transaction compare failed; deploy state changed before it was committed")
}

// getDeployValue is a small helper that is used to explain the error.
func (obj *SimpleDeploy) getDeployValue(ctx context.Context, path string) (string, bool, error) {
	result, err := obj.Client.Get(ctx, path)
	if err != nil {
		return "", false, err
	}

	value, exists := result[path]
	return value, exists, nil
}
