package utils

import (
	"fmt"

	"code.justin.tv/d8a/buddy/cmd/buddy-cli/data"
	"code.justin.tv/d8a/buddy/cmd/buddy-cli/setup"
	"code.justin.tv/d8a/buddy/lib/clusters"
	"code.justin.tv/d8a/buddy/lib/config"
	"code.justin.tv/d8a/buddy/lib/store"
	"code.justin.tv/d8a/buddy/lib/terminal"
	"code.justin.tv/systems/sandstorm/manager"

	"time"

	"code.justin.tv/d8a/buddy/lib/clusters/clusterdb"
	"code.justin.tv/d8a/buddy/lib/sandstorm"
	"github.com/fatih/color"
	multierror "github.com/hashicorp/go-multierror"
	"github.com/pkg/errors"
	"github.com/spf13/cobra"
)

var ()

var rotateKeyCmd = &cobra.Command{
	Use:   "rotate-key [cluster name] [role name]",
	Short: "Seamless user rotation - change the credentials for a database user without downtime or work",
	Long: `Seamless user rotation - change the password for a database account using the following steps:
	
	- Make active the inactive user in a role pair
	- Change its password in sandstorm and the database
	- Move all services using the old, active user to use the new one
	- Make the old user inactive`,
	RunE: func(command *cobra.Command, args []string) error {
		if len(args) != 2 {
			return command.Usage()
		}

		if doPairFix && forceToUser != "" {
			fmt.Println("You cannot specify both --fix and --force, you have to pick one (or neither)!")
			return nil
		}

		clusterName := args[0]
		roleName := args[1]
		sandstormClient := data.RootData.SandstormClient()

		rdsInstance, err := data.RootData.LocateDataStore(false)
		if err != nil {
			return err
		}

		db, err := store.OpenDbConn(rdsInstance, sandstormClient)
		if err != nil {
			return err
		}

		data.RootData.ConfigFile().Cluster, err = store.GetClusters(db)
		if err != nil {
			return err
		}

		foundCluster := data.RootData.ConfigFile().GetCluster(clusterName)
		if foundCluster == nil {
			return fmt.Errorf("There is no cluster named %s.  Did you forget to tag your instances?", clusterName)
		}

		foundRole := foundCluster.GetRolePair(roleName)
		if foundRole == nil {
			return fmt.Errorf("There is no role named %s.  Have you forgotten to add your roles?", roleName)
		}

		if len(foundRole.Users) != 2 {
			return fmt.Errorf("the role pair %s has %d users linked instead of the expected 2", roleName, len(foundRole.Users))
		}

		clusterDb, err := clusterdb.OpenDbConn(foundCluster, sandstormClient, foundCluster.SuperUser)
		if err != nil {
			return err
		}

		users, err := clusterDb.ClusterUsers()
		if err != nil {
			return err
		}

		onUser, offUser, err := findPairUsers(clusterDb, foundRole, users)
		if err != nil {
			return err
		}

		if forceToUser == "" {
			err = checkCurrentCredentials(foundCluster, foundRole, onUser)
			if err != nil {
				return err
			}
		}

		var newPassword string
		//We need to change password & spin up target user, but not if we're --force'ing to an already live user
		if forceToUser == "" || !users[offUser.Name] {
			newPassword = sandstorm.GeneratePassword()
			err = spinUpUser(clusterDb, foundCluster, offUser, newPassword)
		} else {
			newPassword, err = sandstormClient.GetUserPassword(foundCluster, offUser.Name)
		}

		if err != nil {
			return err
		}

		//Test the connection before moving everyone over to it
		_, err = clusterdb.OpenDbConn(foundCluster, data.RootData.SandstormClient(), offUser.Name)
		if err != nil {
			//Things went bad, let's try to back out
			revertUser(clusterDb, offUser)
			return err
		}

		err = writeRoleServices(foundRole, onUser, offUser, newPassword)
		if err != nil {
			return err
		}

		//Wait for old user traffic to drain
		err = awaitTrafficDrain(clusterDb, onUser)
		if err != nil {
			return err
		}

		//Shut down the old user
		err = clusterDb.SetUserLogin(onUser.Name, false)
		if err != nil {
			return err
		}

		fmt.Println("Rotation complete!")

		return nil
	},
}

func findPairUsers(clusterDb *clusterdb.ClusterDB, rolePair *config.RolePair, users map[string]bool) (onUser *config.User, offUser *config.User, err error) {
	//Iterate through the role pair & find the currently-deactivated user & currently-activated user
	for _, user := range rolePair.Users {
		isOn, exists := users[user.Name]
		if !exists {
			err = fmt.Errorf("role-linked user %s does not appear to exist in the database", user.Name)
			return
		}

		//If the user typed --force <user>, then the "deactivated" user needs to be set to the specified user
		if forceToUser == "" {
			if isOn && onUser == nil {
				onUser = user
			} else if !isOn && offUser == nil {
				offUser = user
			} else if !isOn {
				onUser = user
			} else if doPairFix {
				fmt.Println("Checking database traffic to shut down the user not in use...")
				_, err = clusters.FixRoleKeys(clusterDb, rolePair)
				if err != nil {
					return
				}
				fmt.Println("User role pair fixed.")
			} else {
				err = fmt.Errorf("both users attached to role %s (%s and %s) are currently active- deactivate one", rolePair.Name, onUser.Name, user.Name)
				return
			}
		} else {
			if user.Name == forceToUser {
				offUser = user
			} else if onUser == nil {
				onUser = user
			} else {
				err = fmt.Errorf("%s is not a user in the role pair %s", forceToUser, rolePair.Name)
				return
			}
		}
	}

	//If we got this far, we have one active & one inactive user, which means that --fix is unnecessary
	if doPairFix {
		fmt.Printf("Role pair is already fine: %s is active and %s is inactive\n", onUser.Name, offUser.Name)
	}

	return
}

func writeRoleServices(rolePair *config.RolePair, onUser *config.User, offUser *config.User, newPassword string) error {
	allServiceErrors := new(multierror.Error)
	sandstormClient := data.RootData.SandstormClient()

	//Write credentials to all services
	for _, service := range rolePair.RoleServices {
		fmt.Printf("Writing new credentials to service %s\n", service.Name)
		err := sandstormClient.Manager().Put(&manager.Secret{
			Name:      service.UsernameSecret,
			Plaintext: []byte(offUser.Name),
		})

		if err != nil {
			allServiceErrors = multierror.Append(allServiceErrors, errors.Wrap(err, fmt.Sprintf("error updating service %s to use user %s", service.Name, offUser.Name)))
			break
		}

		err = sandstormClient.Manager().Put(&manager.Secret{
			Name:      service.PasswordSecret,
			Plaintext: []byte(newPassword),
		})

		if err != nil {
			allServiceErrors = multierror.Append(allServiceErrors, errors.Wrap(err, fmt.Sprintf("error updating service %s's password to use user %s's password", service.Name, offUser.Name)))
			//Try to revert the username change we just made
			revertErr := sandstormClient.Manager().Put(&manager.Secret{
				Name:      service.UsernameSecret,
				Plaintext: []byte(onUser.Name),
			})
			if revertErr != nil {
				allServiceErrors = multierror.Append(allServiceErrors, errors.Wrap(revertErr, color.RedString("succeeded at updating service %'s user, then failed to update its password, then failed to revert its user - this service is probably about to break!  Use `buddy-cli fix-service`.", service.Name)))
			}
			break
		}
	}

	return allServiceErrors.ErrorOrNil()
}

func spinUpUser(clusterDb *clusterdb.ClusterDB, cluster *config.Cluster, user *config.User, password string) error {
	fmt.Printf("Writing new %s password to sandstorm...\n", user.Name)
	err := data.RootData.SandstormClient().WriteUserPassword(cluster, user.Name, password)
	if err != nil {
		return err
	}

	fmt.Printf("Writing new %s password to %s database...\n", user.Name, cluster.Name)
	err = clusterDb.SetUserPassword(user.Name, password)
	if err != nil {
		return err
	}

	fmt.Printf("Setting user %s to LOGIN\n", user.Name)
	err = clusterDb.SetUserLogin(user.Name, true)
	if err != nil {
		return err
	}

	return nil
}

func awaitTrafficDrain(clusterDb *clusterdb.ClusterDB, drainUser *config.User) error {
	lastSeenOnUser := time.Now()
	return terminal.WaitForCondition(fmt.Sprintf("%s traffic to drain", drainUser.Name), 15*time.Minute, 10*time.Second, func() (bool, error) {
		users, err := clusterDb.ActiveUsers()
		if err != nil {
			return false, err
		}

		foundOnUser := false
		for _, user := range users {
			if user == drainUser.Name {
				foundOnUser = true
				break
			}
		}

		if foundOnUser {
			lastSeenOnUser = time.Now()
			return false, nil
		} else if lastSeenOnUser.Add(5 * time.Minute).Before(time.Now()) {
			return true, nil
		}

		return false, nil
	})
}

func checkCurrentCredentials(cluster *config.Cluster, rolePair *config.RolePair, user *config.User) error {
	sandstormClient := data.RootData.SandstormClient()

	//Test the connection of the current live user
	oldPassword, err := sandstormClient.GetUserPassword(cluster, user.Name)
	if err != nil {
		return err
	}

	_, err = clusterdb.OpenDbConnWithManualSecret(cluster, user.Name, oldPassword)
	if err != nil {
		return errors.Wrap(err, fmt.Sprintf("there is a serious misconfiguration currently- user %s cannot connect to cluster %s with the current sandstorm credentials- use --force <user> if you'd like to continue anyway", user.Name, cluster.Name))
	}

	for _, service := range rolePair.RoleServices {
		userSecret, err := sandstormClient.Manager().Get(service.UsernameSecret)
		if err != nil {
			return err
		}
		servicePasswordSecret, err := sandstormClient.Manager().Get(service.PasswordSecret)
		if err != nil {
			return err
		}

		if user.Name != string(userSecret.Plaintext) || oldPassword != string(servicePasswordSecret.Plaintext) {
			return fmt.Errorf("the current credentials of service %s don't match what's currently live in the %s cluster - use --force <user> if you'd like to continue anyway", service.Name, cluster.Name)
		}
	}

	return nil
}

func revertUser(clusterDb *clusterdb.ClusterDB, user *config.User) {
	revertErr := clusterDb.SetUserLogin(user.Name, false)
	if revertErr != nil {
		fmt.Println(errors.Wrap(revertErr, fmt.Sprintf("error attempting to revert %s back to NOLOGIN", user.Name)))
	}
}

var doPairFix bool
var forceToUser string

func init() {
	setup.RootCmd.AddCommand(rotateKeyCmd)
	rotateKeyCmd.Flags().BoolVarP(&doPairFix, "fix", "", false, "If both users in a role pair are live, try to reset the pair if only one user is getting traffic")
	rotateKeyCmd.Flags().StringVarP(&forceToUser, "force", "", "", "Force-live a user in a role pair, force-move the services over, and then attempt to shut down the other user")
}
