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

import (
	"context"
	"fmt"
	"strings"

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

	etcdtypes "go.etcd.io/etcd/client/pkg/v3/types"
	etcd "go.etcd.io/etcd/client/v3"
	etcdutil "go.etcd.io/etcd/client/v3/clientv3util"
)

// volunteer offers yourself up to be a server if needed. If you specify a nil
// value for urls, then this will unvolunteer yourself.
func (obj *EmbdEtcd) volunteer(ctx context.Context, urls etcdtypes.URLs) error {
	if obj.Debug {
		if urls == nil {
			obj.Logf("unvolunteer...")
			defer obj.Logf("unvolunteer: done!")
		} else {
			obj.Logf("volunteer: %s", urls.String())
			defer obj.Logf("volunteer: done!")
		}
	}
	// volunteer to be a server
	key := fmt.Sprintf(obj.NS+volunteerPathFmt, obj.Hostname)
	ifs := []etcd.Cmp{} // list matching the desired state
	ops := []etcd.Op{}  // list of ops in this txn
	els := []etcd.Op{}
	if urls != nil {
		data := urls.String() // value is usually a peer "serverURL"
		// XXX: bug: https://github.com/etcd-io/etcd/issues/10566
		// XXX: reverse things with els to workaround the bug :(
		//ifs = append(ifs, etcd.Compare(etcd.Value(key), "!=", data)) // desired state
		//ops = append(ops, etcd.OpPut(key, data, etcd.WithLease(obj.leaseID)))
		ifs = append(ifs, etcd.Compare(etcd.Value(key), "=", data)) // desired state
		ifs = append(ifs, etcd.Compare(etcd.LeaseValue(key), "=", obj.leaseID))
		els = append(ops, etcd.OpPut(key, data, etcd.WithLease(obj.leaseID)))

	} else { // delete message if set to erase
		ifs = append(ifs, etcdutil.KeyExists(key)) // desired state
		ops = append(ops, etcd.OpDelete(key))
	}

	_, err := obj.client.Txn(ctx, ifs, ops, els)
	msg := "volunteering failed"
	if urls == nil {
		msg = "unvolunteering failed"
	}
	return errwrap.Wrapf(err, msg)
}

// nominate nominates a particular client to be a server (peer). If you specify
// a nil value for urls, then this will unnominate that member.
func (obj *EmbdEtcd) nominate(ctx context.Context, hostname string, urls etcdtypes.URLs) error {
	if obj.Debug {
		if urls == nil {
			obj.Logf("unnominate(%s)...", hostname)
			defer obj.Logf("unnominate(%s): done!", hostname)
		} else {
			obj.Logf("nominate(%s): %s", hostname, urls.String())
			defer obj.Logf("nominate(%s): done!", hostname)
		}
	}
	// nominate someone to be a server
	key := fmt.Sprintf(obj.NS+nominatedPathFmt, hostname)
	ifs := []etcd.Cmp{} // list matching the desired state
	ops := []etcd.Op{}  // list of ops in this txn
	els := []etcd.Op{}
	if urls != nil {
		data := urls.String()
		// XXX: bug: https://github.com/etcd-io/etcd/issues/10566
		// XXX: reverse things with els to workaround the bug :(
		//ifs = append(ifs, etcd.Compare(etcd.Value(key), "!=", data)) // desired state
		//ops = append(ops, etcd.OpPut(key, data)) // TODO: add a TTL? (etcd.WithLease)
		ifs = append(ifs, etcd.Compare(etcd.Value(key), "=", data)) // desired state
		els = append(ops, etcd.OpPut(key, data))                    // TODO: add a TTL? (etcd.WithLease)

	} else { // delete message if set to erase
		ifs = append(ifs, etcdutil.KeyExists(key)) // desired state
		ops = append(ops, etcd.OpDelete(key))
	}

	_, err := obj.client.Txn(ctx, ifs, ops, els)
	msg := "nominate failed"
	if urls == nil {
		msg = "unnominate failed"
	}
	return errwrap.Wrapf(err, msg)
}

// advertise idempotently advertises the list of available client endpoints for
// the given member. If you specify a nil value for urls, then this will remove
// that member.
func (obj *EmbdEtcd) advertise(ctx context.Context, hostname string, urls etcdtypes.URLs) error {
	if obj.Debug {
		if urls == nil {
			obj.Logf("unadvertise(%s)...", hostname)
			defer obj.Logf("unadvertise(%s): done!", hostname)
		} else {
			obj.Logf("advertise(%s): %s", hostname, urls.String())
			defer obj.Logf("advertise(%s): done!", hostname)
		}
	}
	// advertise endpoints
	key := fmt.Sprintf(obj.NS+endpointsPathFmt, hostname)
	ifs := []etcd.Cmp{} // list matching the desired state
	ops := []etcd.Op{}  // list of ops in this txn
	els := []etcd.Op{}
	if urls != nil {
		data := urls.String() // value is usually a "clientURL"
		// XXX: bug: https://github.com/etcd-io/etcd/issues/10566
		// XXX: reverse things with els to workaround the bug :(
		//ifs = append(ifs, etcd.Compare(etcd.Value(key), "!=", data)) // desired state
		//ops = append(ops, etcd.OpPut(key, data, etcd.WithLease(obj.leaseID)))
		ifs = append(ifs, etcd.Compare(etcd.Value(key), "=", data)) // desired state
		ifs = append(ifs, etcd.Compare(etcd.LeaseValue(key), "=", obj.leaseID))
		els = append(ops, etcd.OpPut(key, data, etcd.WithLease(obj.leaseID)))
	} else { // delete in this case
		ifs = append(ifs, etcdutil.KeyExists(key)) // desired state
		ops = append(ops, etcd.OpDelete(key))
	}

	_, err := obj.client.Txn(ctx, ifs, ops, els)
	msg := "advertising failed"
	if urls == nil {
		msg = "unadvertising failed"
	}
	return errwrap.Wrapf(err, msg)
}

// getVolunteers returns a urls map of available etcd server volunteers.
func (obj *EmbdEtcd) getVolunteers(ctx context.Context) (etcdtypes.URLsMap, error) {
	if obj.Debug {
		obj.Logf("getVolunteers()")
		defer obj.Logf("getVolunteers(): done!")
	}
	p := obj.NS + VolunteerPath
	keyMap, err := obj.client.Get(ctx, p, etcd.WithPrefix())
	if err != nil {
		return nil, errwrap.Wrapf(err, "can't get peer volunteers")
	}
	volunteers := make(etcdtypes.URLsMap)
	for key, val := range keyMap { // loop through directory of volunteers
		if !strings.HasPrefix(key, p) {
			continue
		}
		name := key[len(p):] // get name of volunteer
		if val == "" {       // skip "erased" values
			continue
		}
		urls, err := etcdtypes.NewURLs(strings.Split(val, ","))
		if err != nil {
			return nil, errwrap.Wrapf(err, "data format error")
		}
		volunteers[name] = urls // add to map
	}
	return volunteers, nil
}

// getNominated returns a urls map of nominated etcd server volunteers.
// NOTE: I know 'nominees' might be more correct, but is less consistent here
func (obj *EmbdEtcd) getNominated(ctx context.Context) (etcdtypes.URLsMap, error) {
	if obj.Debug {
		obj.Logf("getNominated()")
		defer obj.Logf("getNominated(): done!")
	}
	p := obj.NS + NominatedPath
	keyMap, err := obj.client.Get(ctx, p, etcd.WithPrefix()) // map[string]string, bool
	if err != nil {
		return nil, errwrap.Wrapf(err, "can't get nominated peers")
	}
	nominated := make(etcdtypes.URLsMap)
	for key, val := range keyMap { // loop through directory of nominated
		if !strings.HasPrefix(key, p) {
			continue
		}
		name := key[len(p):] // get name of nominee
		if val == "" {       // skip "erased" values
			continue
		}
		urls, err := etcdtypes.NewURLs(strings.Split(val, ","))
		if err != nil {
			return nil, errwrap.Wrapf(err, "data format error")
		}
		nominated[name] = urls // add to map
	}
	return nominated, nil
}

// getEndpoints returns a urls map of available endpoints for clients.
func (obj *EmbdEtcd) getEndpoints(ctx context.Context) (etcdtypes.URLsMap, error) {
	if obj.Debug {
		obj.Logf("getEndpoints()")
		defer obj.Logf("getEndpoints(): done!")
	}
	p := obj.NS + EndpointsPath
	keyMap, err := obj.client.Get(ctx, p, etcd.WithPrefix())
	if err != nil {
		return nil, errwrap.Wrapf(err, "can't get client endpoints")
	}
	endpoints := make(etcdtypes.URLsMap)
	for key, val := range keyMap { // loop through directory of endpoints
		if !strings.HasPrefix(key, p) {
			continue
		}
		name := key[len(p):] // get name of volunteer
		if val == "" {       // skip "erased" values
			continue
		}
		urls, err := etcdtypes.NewURLs(strings.Split(val, ","))
		if err != nil {
			return nil, errwrap.Wrapf(err, "data format error")
		}
		endpoints[name] = urls // add to map
	}
	return endpoints, nil
}
