package app

import (
	"context"
	"errors"
	"fmt"
	"strings"

	"coopcloud.tech/abra/cli/internal"
	appPkg "coopcloud.tech/abra/pkg/app"
	"coopcloud.tech/abra/pkg/autocomplete"
	"coopcloud.tech/abra/pkg/client"
	"coopcloud.tech/abra/pkg/config"
	"coopcloud.tech/abra/pkg/deploy"
	"coopcloud.tech/abra/pkg/formatter"
	"coopcloud.tech/abra/pkg/i18n"
	"coopcloud.tech/abra/pkg/lint"
	"coopcloud.tech/abra/pkg/log"
	"coopcloud.tech/abra/pkg/recipe"
	stack "coopcloud.tech/abra/pkg/upstream/stack"
	"coopcloud.tech/tagcmp"
	"github.com/AlecAivazis/survey/v2"
	dockerClient "github.com/docker/docker/client"
	"github.com/spf13/cobra"
)

// translators: `abra app upgrade` aliases. use a comma separated list of aliases with
// no spaces in between
var appUpgradeAliases = i18n.G("up")

var AppUpgradeCommand = &cobra.Command{
	// translators: `app upgrade` command
	Use:     i18n.G("upgrade <domain> [version] [flags]"),
	Aliases: strings.Split(appUpgradeAliases, ","),
	// translators: Short description for `app upgrade` command
	Short: i18n.G("Upgrade an app"),
	Long: i18n.G(`Upgrade an app.

Unlike "abra app deploy", chaos operations are not supported here. Only recipe
versions are supported values for "[version]".

It is possible to "--force/-f" an upgrade if you want to re-deploy a specific
version.

Only the deployed version is consulted when trying to determine what upgrades
are available. The live deployment version is the "source of truth" in this
case. The stored .env version is not consulted.

An upgrade can be destructive, please ensure you have a copy of your app data
beforehand. See "abra app backup" for more.`),
	Args: cobra.RangeArgs(1, 2),
	ValidArgsFunction: func(
		cmd *cobra.Command,
		args []string,
		toComplete string,
	) ([]string, cobra.ShellCompDirective) {
		switch l := len(args); l {
		case 0:
			return autocomplete.AppNameComplete()
		case 1:
			app, err := appPkg.Get(args[0])
			if err != nil {
				return []string{i18n.G("autocomplete failed: %s", err)}, cobra.ShellCompDirectiveError
			}
			return autocomplete.RecipeVersionComplete(app.Recipe.Name)
		default:
			return nil, cobra.ShellCompDirectiveError
		}
	},
	Run: func(cmd *cobra.Command, args []string) {
		var (
			upgradeWarnMessages []string
			chosenUpgrade       string
			availableUpgrades   []string
			upgradeReleaseNotes string
		)

		app := internal.ValidateApp(args)

		if err := app.Recipe.Ensure(recipe.EnsureContext{
			Chaos:   internal.Chaos,
			Offline: internal.Offline,
			// Ignore the env version for now, to make sure we are at the latest commit.
			// This enables us to get release notes, that were added after a release.
			IgnoreEnvVersion: true,
		}); err != nil {
			log.Fatal(err)
		}

		cl, err := client.New(app.Server)
		if err != nil {
			log.Fatal(err)
		}

		deployMeta, err := ensureDeployed(cl, app)
		if err != nil {
			log.Fatal(err)
		}

		if err := lint.LintForErrors(app.Recipe); err != nil {
			log.Fatal(err)
		}

		versions, err := app.Recipe.Tags()
		if err != nil {
			log.Fatal(err)
		}

		// NOTE(d1): we've no idea what the live deployment version is, so every
		// possible upgrade can be shown. it's up to the user to make the choice
		if deployMeta.Version == config.UNKNOWN_DEFAULT {
			availableUpgrades = versions
		}

		if len(args) == 2 && args[1] != "" {
			chosenUpgrade = args[1]

			if err := validateUpgradeVersionArg(chosenUpgrade, app, deployMeta); err != nil {
				log.Fatal(err)
			}

			availableUpgrades = append(availableUpgrades, chosenUpgrade)
		}

		if deployMeta.Version != config.UNKNOWN_DEFAULT && chosenUpgrade == "" {
			upgradeAvailable, err := ensureUpgradesAvailable(app, versions, &availableUpgrades, deployMeta)
			if err != nil {
				log.Fatal(err)
			}

			if !upgradeAvailable {
				log.Info(i18n.G("no available upgrades"))
				return
			}
		}

		if internal.Force || internal.NoInput || chosenUpgrade != "" {
			if len(availableUpgrades) > 0 {
				chosenUpgrade = availableUpgrades[len(availableUpgrades)-1]
			}
		} else {
			if err := chooseUpgrade(availableUpgrades, deployMeta, &chosenUpgrade); err != nil {
				log.Fatal(err)
			}
		}

		if internal.Force &&
			chosenUpgrade == "" &&
			deployMeta.Version != config.UNKNOWN_DEFAULT {
			chosenUpgrade = deployMeta.Version
		}

		if chosenUpgrade == "" {
			log.Fatal(i18n.G("unknown deployed version, unable to upgrade"))
		}

		log.Debug(i18n.G("choosing %s as version to upgrade", chosenUpgrade))

		// Get the release notes before checking out the new version in the
		// recipe. This enables us to get release notes, that were added after
		// a release.
		if err := getReleaseNotes(app, versions, chosenUpgrade, deployMeta, &upgradeReleaseNotes); err != nil {
			log.Fatal(err)
		}

		if _, err := app.Recipe.EnsureVersion(chosenUpgrade); err != nil {
			log.Fatal(err)
		}

		if err := deploy.MergeAbraShEnv(app.Recipe, app.Env); err != nil {
			log.Fatal(err)
		}

		composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
		if err != nil {
			log.Fatal(err)
		}

		stackName := app.StackName()
		deployOpts := stack.Deploy{
			Composefiles:     composeFiles,
			Namespace:        stackName,
			Prune:            false,
			ResolveImage:     stack.ResolveImageAlways,
			Detach:           false,
			SendRegistryAuth: true,
		}

		compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
		if err != nil {
			log.Fatal(err)
		}

		newRecipeWithUpgradeVersion := fmt.Sprintf("%s:%s", app.Recipe.Name, chosenUpgrade)
		appPkg.ExposeAllEnv(stackName, compose, app.Env, newRecipeWithUpgradeVersion)

		appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name)
		appPkg.SetChaosLabel(compose, stackName, internal.Chaos)
		if internal.Chaos {
			appPkg.SetChaosVersionLabel(compose, stackName, chosenUpgrade)
		}

		envVars, err := appPkg.CheckEnv(app)
		if err != nil {
			log.Fatal(err)
		}

		for _, envVar := range envVars {
			if !envVar.Present {
				upgradeWarnMessages = append(upgradeWarnMessages,
					i18n.G("%s missing from %s.env", envVar.Name, app.Domain),
				)
			}
		}

		// Gather secrets
		secretInfo, err := deploy.GatherSecretsForDeploy(cl, app, internal.ShowUnchanged)
		if err != nil {
			log.Fatal(err)
		}

		// Gather configs
		configInfo, err := deploy.GatherConfigsForDeploy(cl, app, compose, app.Env, internal.ShowUnchanged)
		if err != nil {
			log.Fatal(err)
		}

		// Gather images
		imageInfo, err := deploy.GatherImagesForDeploy(cl, app, compose, internal.ShowUnchanged)
		if err != nil {
			log.Fatal(err)
		}

		if showReleaseNotes {
			fmt.Print(upgradeReleaseNotes)
			return
		}

		if upgradeReleaseNotes == "" {
			upgradeWarnMessages = append(
				upgradeWarnMessages,
				i18n.G("no release notes for upgrading from %s to %s", deployMeta.Version, chosenUpgrade),
			)
		}

		deployedVersion := deployMeta.Version
		if deployMeta.IsChaos {
			deployedVersion = deployMeta.ChaosVersion
		}

		if err := internal.DeployOverview(
			app,
			deployedVersion,
			chosenUpgrade,
			upgradeReleaseNotes,
			upgradeWarnMessages,
			secretInfo,
			configInfo,
			imageInfo,
		); err != nil {
			log.Fatal(err)
		}

		stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName)
		if err != nil {
			log.Fatal(err)
		}

		serviceNames, err := appPkg.GetAppServiceNames(app.Name)
		if err != nil {
			log.Fatal(err)
		}

		f, err := app.Filters(true, false, serviceNames...)
		if err != nil {
			log.Fatal(err)
		}

		if err := stack.RunDeploy(
			cl,
			deployOpts,
			compose,
			stackName,
			app.Server,
			internal.DontWaitConverge,
			internal.NoInput,
			f,
		); err != nil {
			log.Fatal(err)
		}

		postDeployCmds, ok := app.Env["POST_UPGRADE_CMDS"]
		if ok && !internal.DontWaitConverge {
			log.Debug(i18n.G("run the following post-deploy commands: %s", postDeployCmds))

			if err := internal.PostCmds(cl, app, postDeployCmds); err != nil {
				log.Fatal(i18n.G("attempting to run post deploy commands, saw: %s", err))
			}
		}

		if err := app.WriteRecipeVersion(chosenUpgrade, false); err != nil {
			log.Fatal(i18n.G("writing recipe version failed: %s", err))
		}
	},
}

// chooseUpgrade prompts the user to choose an upgrade interactively.
func chooseUpgrade(
	availableUpgrades []string,
	deployMeta stack.DeployMeta,
	chosenUpgrade *string,
) error {
	msg := i18n.G("please select an upgrade (version: %s):", deployMeta.Version)

	if deployMeta.IsChaos {
		chaosVersion := formatter.BoldDirtyDefault(deployMeta.ChaosVersion)

		msg = i18n.G(
			"please select an upgrade (version: %s, chaos: %s):",
			deployMeta.Version,
			chaosVersion,
		)
	}

	prompt := &survey.Select{
		Message: msg,
		Options: internal.SortVersionsDesc(availableUpgrades),
	}

	if err := survey.AskOne(prompt, chosenUpgrade); err != nil {
		return err
	}

	return nil
}

func getReleaseNotes(
	app appPkg.App,
	versions []string,
	chosenUpgrade string,
	deployMeta stack.DeployMeta,
	upgradeReleaseNotes *string,
) error {
	parsedChosenUpgrade, err := tagcmp.Parse(chosenUpgrade)
	if err != nil {
		return errors.New(i18n.G("parsing chosen upgrade version failed: %s", err))
	}

	parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
	if err != nil {
		return errors.New(i18n.G("parsing deployment version failed: %s", err))
	}

	for _, version := range internal.SortVersionsDesc(versions) {
		parsedVersion, err := tagcmp.Parse(version)
		if err != nil {
			return errors.New(i18n.G("parsing recipe version failed: %s", err))
		}

		if parsedVersion.IsGreaterThan(parsedDeployedVersion) &&
			parsedVersion.IsLessThan(parsedChosenUpgrade) {
			note, err := app.Recipe.GetReleaseNotes(version, app.Domain)
			if err != nil {
				return err
			}

			if note != "" {
				// NOTE(d1): trim any final newline on the end of the note itself before
				//           we manually handle newlines (for multiple release notes and
				//           ensuring space between the warning messages)
				note = strings.TrimSuffix(note, "\n")

				*upgradeReleaseNotes += fmt.Sprintf("%s\n", note)
			}
		}
	}

	return nil
}

// ensureUpgradesAvailable ensures that there are available upgrades.
func ensureUpgradesAvailable(
	app appPkg.App,
	versions []string,
	availableUpgrades *[]string,
	deployMeta stack.DeployMeta,
) (bool, error) {
	parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
	if err != nil {
		return false, errors.New(i18n.G("parsing deployed version failed: %s", err))
	}

	for _, version := range versions {
		parsedVersion, err := tagcmp.Parse(version)
		if err != nil {
			return false, errors.New(i18n.G("parsing recipe version failed: %s", err))
		}

		if parsedVersion.IsGreaterThan(parsedDeployedVersion) &&
			!(parsedVersion.Equals(parsedDeployedVersion)) {
			*availableUpgrades = append(*availableUpgrades, version)
		}
	}

	if len(*availableUpgrades) == 0 && !internal.Force {
		return false, nil
	}

	return true, nil
}

// validateUpgradeVersionArg validates the specific version.
func validateUpgradeVersionArg(
	specificVersion string,
	app appPkg.App,
	deployMeta stack.DeployMeta,
) error {
	parsedSpecificVersion, err := tagcmp.Parse(specificVersion)
	if err != nil {
		return errors.New(i18n.G("'%s' is not a known version for %s", specificVersion, app.Recipe.Name))
	}

	parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
	if err != nil {
		return errors.New(i18n.G("'%s' is not a known version", deployMeta.Version))
	}

	if parsedSpecificVersion.IsLessThan(parsedDeployedVersion) &&
		!parsedSpecificVersion.Equals(parsedDeployedVersion) {
		return errors.New(i18n.G("%s is not an upgrade for %s?", deployMeta.Version, specificVersion))
	}

	if parsedSpecificVersion.Equals(parsedDeployedVersion) && !internal.Force {
		return errors.New(i18n.G("%s is not an upgrade for %s?", deployMeta.Version, specificVersion))
	}

	return nil
}

// ensureDeployed ensures the app is deployed and if so, returns deployment
// meta info.
func ensureDeployed(cl *dockerClient.Client, app appPkg.App) (stack.DeployMeta, error) {
	log.Debug(i18n.G("checking whether %s is already deployed", app.StackName()))

	deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
	if err != nil {
		return stack.DeployMeta{}, err
	}

	if !deployMeta.IsDeployed {
		return stack.DeployMeta{}, errors.New(i18n.G("%s is not deployed?", app.Name))
	}

	return deployMeta, nil
}

var showReleaseNotes bool

func init() {
	AppUpgradeCommand.Flags().BoolVarP(
		&internal.Force,
		i18n.G("force"),
		i18n.G("f"),
		false,
		i18n.G("perform action without further prompt"),
	)

	AppUpgradeCommand.Flags().BoolVarP(
		&internal.NoDomainChecks,
		i18n.G("no-domain-checks"),
		i18n.G("D"),
		false,
		i18n.G("disable public DNS checks"),
	)

	AppUpgradeCommand.Flags().BoolVarP(
		&internal.DontWaitConverge,
		i18n.G("no-converge-checks"),
		i18n.G("c"),
		false,
		i18n.G("disable converge logic checks"),
	)

	AppUpgradeCommand.Flags().BoolVarP(
		&showReleaseNotes,
		i18n.G("releasenotes"),
		i18n.G("r"),
		false,
		i18n.G("only show release notes"),
	)

	AppUpgradeCommand.Flags().BoolVarP(
		&internal.ShowUnchanged,
		i18n.G("show-unchanged"),
		i18n.G("U"),
		false,
		i18n.G("show all configs & images, including unchanged ones"),
	)
}
