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

import (
	"context"
	"fmt"
	"os"
	"os/signal"

	cliUtil "github.com/purpleidea/mgmt/cli/util"
	"github.com/purpleidea/mgmt/engine"
	"github.com/purpleidea/mgmt/etcd"
	"github.com/purpleidea/mgmt/etcd/client"
	etcdfs "github.com/purpleidea/mgmt/etcd/fs"
	etcdSSH "github.com/purpleidea/mgmt/etcd/ssh"
	"github.com/purpleidea/mgmt/gapi"
	"github.com/purpleidea/mgmt/lib"
	"github.com/purpleidea/mgmt/util"
	"github.com/purpleidea/mgmt/util/errwrap"

	git "github.com/go-git/go-git/v5"
	"github.com/google/uuid"
)

// DeployArgs is the CLI parsing structure and type of the parsed result. This
// particular one contains all the common flags for the `deploy` subcommand
// which all frontends can use.
type DeployArgs struct {
	// SSHURL can be specified if we want to transport the SSH client
	// connection over SSH. If this is specified, the second hop is made
	// with the Seeds values, but they connect from this destination. You
	// can specify this in the standard james@server:22 format. This will
	// use your ~/.ssh/ directory for public key authentication and
	// verifying the host key in the known_hosts file. This must already be
	// setup for things to work.
	SSHURL string `arg:"--ssh-url" help:"transport the etcd client connection over SSH to this server"`

	Seeds []string `arg:"--seeds,env:MGMT_SEEDS" help:"default etcd client endpoints"`
	Noop  bool     `arg:"--noop" help:"globally force all resources into no-op mode"`
	Sema  int      `arg:"--sema" default:"-1" help:"globally add a semaphore to all resources with this lock count"`
	NoGit bool     `arg:"--no-git" help:"don't look at git commit id for safe deploys"`
	Force bool     `arg:"--force" help:"force a new deploy, even if the safety chain would break"`

	NoAutoEdges bool `arg:"--no-autoedges" help:"skip the autoedges stage"`

	DeployEmpty      *cliUtil.EmptyArgs      `arg:"subcommand:empty" help:"deploy empty payload"`
	DeployLang       *cliUtil.LangArgs       `arg:"subcommand:lang" help:"deploy lang (mcl) payload"`
	DeployYaml       *cliUtil.YamlArgs       `arg:"subcommand:yaml" help:"deploy yaml graph payload"`
	DeployPuppet     *cliUtil.PuppetArgs     `arg:"subcommand:puppet" help:"deploy puppet graph payload"`
	DeployLangPuppet *cliUtil.LangPuppetArgs `arg:"subcommand:langpuppet" help:"deploy langpuppet graph payload"`
}

// Run executes the correct subcommand. It errors if there's ever an error. It
// returns true if we did activate one of the subcommands. It returns false if
// we did not. This information is used so that the top-level parser can return
// usage or help information if no subcommand activates. This particular Run is
// the run for the main `deploy` subcommand. This always requires a frontend to
// deploy to the cluster, but if you don't want a graph, you can use the `empty`
// frontend. The engine backend is agnostic to which frontend is deployed, in
// fact, you can deploy with multiple different frontends, one after another, on
// the same engine.
func (obj *DeployArgs) Run(ctx context.Context, data *cliUtil.Data) (bool, error) {
	var name string
	var args interface{}
	if cmd := obj.DeployEmpty; cmd != nil {
		name = cliUtil.LookupSubcommand(obj, cmd) // "empty"
		args = cmd
	}
	if cmd := obj.DeployLang; cmd != nil {
		name = cliUtil.LookupSubcommand(obj, cmd) // "lang"
		args = cmd
	}
	if cmd := obj.DeployYaml; cmd != nil {
		name = cliUtil.LookupSubcommand(obj, cmd) // "yaml"
		args = cmd
	}
	if cmd := obj.DeployPuppet; cmd != nil {
		name = cliUtil.LookupSubcommand(obj, cmd) // "puppet"
		args = cmd
	}
	if cmd := obj.DeployLangPuppet; cmd != nil {
		name = cliUtil.LookupSubcommand(obj, cmd) // "langpuppet"
		args = cmd
	}

	// XXX: workaround https://github.com/alexflint/go-arg/issues/239
	gapiNames := gapi.Names() // list of registered names
	if l := len(obj.Seeds); name == "" && l > 1 {
		elem := obj.Seeds[l-2] // second to last element
		if util.StrInList(elem, gapiNames) {
			return false, cliUtil.CliParseError(cliUtil.MissingEquals) // consistent errors
		}
	}

	fn, exists := gapi.RegisteredGAPIs[name]
	if !exists {
		return false, nil // did not activate
	}
	gapiObj := fn()

	program, version := data.Program, data.Version
	Logf := func(format string, v ...interface{}) {
		data.Flags.Logf("deploy: "+format, v...)
	}

	// TODO: consider adding a timeout based on an args.Timeout flag ?
	ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
	defer cancel()

	cliUtil.Hello(program, version, data.Flags) // say hello!
	defer Logf("goodbye!")

	var hash, pHash string
	if !obj.NoGit {
		wd, err := os.Getwd()
		if err != nil {
			return false, errwrap.Wrapf(err, "could not get current working directory")
		}
		repo, err := git.PlainOpen(wd)
		if err != nil {
			return false, errwrap.Wrapf(err, "could not open git repo")
		}

		head, err := repo.Head()
		if err != nil {
			return false, errwrap.Wrapf(err, "could not read git HEAD")
		}

		hash = head.Hash().String() // current commit id
		Logf("hash: %s", hash)

		lo := &git.LogOptions{
			From: head.Hash(),
		}
		commits, err := repo.Log(lo)
		if err != nil {
			return false, errwrap.Wrapf(err, "could not read git log")
		}
		if _, err := commits.Next(); err != nil { // skip over HEAD
			return false, errwrap.Wrapf(err, "could not read HEAD in git log") // weird!
		}
		commit, err := commits.Next()
		if err == nil { // errors are okay, we might be empty
			pHash = commit.Hash.String() // previous commit id
		}
		Logf("previous deploy hash: %s", pHash)
		if obj.Force {
			pHash = "" // don't check this :(
		}
		if hash == "" {
			return false, errwrap.Wrapf(err, "could not get git deploy hash")
		}
	}

	uniqueid := uuid.New() // panic's if it can't generate one :P

	etcdClient := client.NewClientFromSeedsNamespace(
		obj.Seeds, // endpoints
		lib.NS,
	)
	if err := etcdClient.Init(); err != nil {
		return false, errwrap.Wrapf(err, "client Init failed")
	}
	defer func() {
		err := errwrap.Wrapf(etcdClient.Close(), "client Close failed")
		if err != nil {
			// TODO: cause the final exit code to be non-zero
			Logf("client cleanup error: %+v", err)
		}
	}()

	var world engine.World
	world = &etcd.World{ // XXX: What should some of these fields be?
		Client: etcdClient, // XXX: remove me when etcdfs below is done
		Seeds:  obj.Seeds,
		NS:     lib.NS,
		//MetadataPrefix: lib.MetadataPrefix,
		//StoragePrefix:  lib.StoragePrefix,
		//StandaloneFs: ???.DeployFs, // used for static deploys
		//GetURI: func() string {
		//},
	}
	if obj.SSHURL != "" { // alternate world implementation over SSH
		world = &etcdSSH.World{
			URL:   obj.SSHURL,
			Seeds: obj.Seeds,
			NS:    lib.NS,
			//MetadataPrefix: lib.MetadataPrefix,
			//StoragePrefix:  lib.StoragePrefix,
			//StandaloneFs: ???.DeployFs, // used for static deploys
			//GetURI: func() string {
			//},
		}
		// XXX: We need to first get rid of the standalone etcd client,
		// and then pull the etcdfs stuff in so it uses that client.
		return false, fmt.Errorf("--ssh-url is not implemented yet")
	}
	worldInit := &engine.WorldInit{
		Hostname: "", // XXX: Should we set this?
		Debug:    data.Flags.Debug,
		Logf: func(format string, v ...interface{}) {
			Logf("world: etcd: "+format, v...)
		},
	}
	if err := world.Init(worldInit); err != nil {
		return false, errwrap.Wrapf(err, "world Init failed")
	}
	defer func() {
		err := errwrap.Wrapf(world.Close(), "world Close failed")
		if err != nil {
			// TODO: cause the final exit code to be non-zero?
			Logf("close error: %+v", err)
		}
	}()

	// get max id (from all the previous deploys)
	max, err := world.GetMaxDeployID(ctx)
	if err != nil {
		return false, errwrap.Wrapf(err, "error getting max deploy id")
	}
	// find the latest id
	var id = max + 1 // next id
	Logf("previous max deploy id: %d", max)

	// XXX: Get this from the World API? (Which might need improving!)
	etcdFs := &etcdfs.Fs{
		Client: etcdClient,
		// TODO: using a uuid is meant as a temporary measure, i hate them
		Metadata:   lib.MetadataPrefix + fmt.Sprintf("/deploy/%d-%s", id, uniqueid),
		DataPrefix: lib.StoragePrefix,

		Debug: data.Flags.Debug,
		Logf: func(format string, v ...interface{}) {
			Logf("fs: "+format, v...)
		},
	}

	info := &gapi.Info{
		Args: args,
		Flags: &gapi.Flags{
			Noop: obj.Noop,
			Sema: obj.Sema,
			//Update: obj.Update,
		},

		Fs:    etcdFs,
		Debug: data.Flags.Debug,
		Logf: func(format string, v ...interface{}) {
			// TODO: is this a sane prefix to use here?
			data.Flags.Logf("cli: "+format, v...)
		},
	}

	deploy, err := gapiObj.Cli(info)
	if err != nil {
		return false, cliUtil.CliParseError(err) // consistent errors
	}
	if deploy == nil { // not used
		return false, fmt.Errorf("not enough information specified")
	}

	// redundant
	deploy.Noop = obj.Noop
	deploy.Sema = obj.Sema

	deploy.NoAutoEdges = obj.NoAutoEdges

	str, err := deploy.ToB64()
	if err != nil {
		return false, errwrap.Wrapf(err, "encoding error")
	}

	Logf("pushing...")
	// this nominally checks the previous git hash matches our expectation
	if err := world.AddDeploy(ctx, id, hash, pHash, &str); err != nil {
		return false, errwrap.Wrapf(err, "could not create deploy id `%d`", id)
	}
	Logf("success, id: %d", id)
	return true, nil
}
