package recipe

import (
	"bufio"
	"encoding/json"
	"fmt"
	"os"
	"path"
	"sort"
	"strings"

	"coopcloud.tech/abra/cli/internal"
	"coopcloud.tech/abra/pkg/autocomplete"
	"coopcloud.tech/abra/pkg/client"
	"coopcloud.tech/abra/pkg/formatter"
	gitPkg "coopcloud.tech/abra/pkg/git"
	"coopcloud.tech/abra/pkg/log"
	recipePkg "coopcloud.tech/abra/pkg/recipe"
	"coopcloud.tech/tagcmp"
	"github.com/AlecAivazis/survey/v2"
	"github.com/distribution/reference"
	"github.com/spf13/cobra"
)

type imgPin struct {
	image   string
	version tagcmp.Tag
}

// anUpgrade represents a single service upgrade (as within a recipe), and the
// list of tags that it can be upgraded to, for serialization purposes.
type anUpgrade struct {
	Service     string   `json:"service"`
	Image       string   `json:"image"`
	Tag         string   `json:"tag"`
	UpgradeTags []string `json:"upgrades"`
}

var RecipeUpgradeCommand = &cobra.Command{
	Use:     "upgrade <recipe> [flags]",
	Aliases: []string{"u"},
	Short:   "Upgrade recipe image tags",
	Long: `Upgrade a given <recipe> configuration.

It will update the relevant compose file tags on the local file system.

Some image tags cannot be parsed because they do not follow some sort of
semver-like convention. In this case, all possible tags will be listed and it
is up to the end-user to decide.

The command is interactive and will show a select input which allows you to
make a seclection. Use the "?" key to see more help on navigating this
interface.

You may invoke this command in "wizard" mode and be prompted for input.`,
	Args: cobra.RangeArgs(0, 1),
	ValidArgsFunction: func(
		cmd *cobra.Command,
		args []string,
		toComplete string) ([]string, cobra.ShellCompDirective) {
		return autocomplete.RecipeNameComplete()
	},
	Run: func(cmd *cobra.Command, args []string) {
		recipe := internal.ValidateRecipe(args, cmd.Name())

		if err := recipe.Ensure(internal.GetEnsureContext()); err != nil {
			log.Fatal(err)
		}

		bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
		if bumpType != 0 {
			// a bitwise check if the number is a power of 2
			if (bumpType & (bumpType - 1)) != 0 {
				log.Fatal("you can only use one of: --major, --minor, --patch.")
			}
		}

		if internal.MachineReadable {
			// -m implies -n in this case
			internal.NoInput = true
		}

		upgradeList := make(map[string]anUpgrade)

		// check for versions file and load pinned versions
		versionsPresent := false
		versionsPath := path.Join(recipe.Dir, "versions")
		servicePins := make(map[string]imgPin)
		if _, err := os.Stat(versionsPath); err == nil {
			log.Debugf("found versions file for %s", recipe.Name)
			file, err := os.Open(versionsPath)
			if err != nil {
				log.Fatal(err)
			}
			scanner := bufio.NewScanner(file)
			for scanner.Scan() {
				line := scanner.Text()
				splitLine := strings.Split(line, " ")
				if splitLine[0] != "pin" || len(splitLine) != 3 {
					log.Fatalf("malformed version pin specification: %s", line)
				}
				pinSlice := strings.Split(splitLine[2], ":")
				pinTag, err := tagcmp.Parse(pinSlice[1])
				if err != nil {
					log.Fatal(err)
				}
				pin := imgPin{
					image:   pinSlice[0],
					version: pinTag,
				}
				servicePins[splitLine[1]] = pin
			}
			if err := scanner.Err(); err != nil {
				log.Error(err)
			}
			versionsPresent = true
		} else {
			log.Debugf("did not find versions file for %s", recipe.Name)
		}

		config, err := recipe.GetComposeConfig(nil)
		if err != nil {
			log.Fatal(err)
		}

		for _, service := range config.Services {
			img, err := reference.ParseNormalizedNamed(service.Image)
			if err != nil {
				log.Fatal(err)
			}

			regVersions, err := client.GetRegistryTags(img)
			if err != nil {
				log.Fatal(err)
			}

			image := reference.Path(img)
			log.Debugf("retrieved %s from remote registry for %s", regVersions, image)
			image = formatter.StripTagMeta(image)

			switch img.(type) {
			case reference.NamedTagged:
				if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
					log.Debugf("%s not considered semver-like", img.(reference.NamedTagged).Tag())
				}
			default:
				log.Warnf("unable to read tag for image %s, is it missing? skipping upgrade for %s", image, service.Name)
				continue
			}

			tag, err := tagcmp.Parse(img.(reference.NamedTagged).Tag())
			if err != nil {
				log.Warnf("unable to parse %s, error was: %s, skipping upgrade for %s", image, err.Error(), service.Name)
				continue
			}

			log.Debugf("parsed %s for %s", tag, service.Name)

			var compatible []tagcmp.Tag
			for _, regVersion := range regVersions {
				other, err := tagcmp.Parse(regVersion)
				if err != nil {
					continue // skip tags that cannot be parsed
				}

				if tag.IsCompatible(other) && tag.IsLessThan(other) && !tag.Equals(other) {
					compatible = append(compatible, other)
				}
			}

			log.Debugf("detected potential upgradable tags %s for %s", compatible, service.Name)

			sort.Sort(tagcmp.ByTagDesc(compatible))

			if len(compatible) == 0 && !allTags {
				log.Info(fmt.Sprintf("no new versions available for %s, assuming %s is the latest (use -a/--all-tags to see all anyway)", image, tag))
				continue // skip on to the next tag and don't update any compose files
			}

			catlVersions, err := recipePkg.VersionsOfService(recipe.Name, service.Name, internal.Offline)
			if err != nil {
				log.Fatal(err)
			}

			compatibleStrings := []string{"skip"}
			for _, compat := range compatible {
				skip := false
				for _, catlVersion := range catlVersions {
					if compat.String() == catlVersion {
						skip = true
					}
				}
				if !skip {
					compatibleStrings = append(compatibleStrings, compat.String())
				}
			}

			log.Debugf("detected compatible upgradable tags %s for %s", compatibleStrings, service.Name)

			var upgradeTag string
			_, ok := servicePins[service.Name]
			if versionsPresent && ok {
				pinnedTag := servicePins[service.Name].version
				if tag.IsLessThan(pinnedTag) {
					pinnedTagString := pinnedTag.String()
					contains := false
					for _, v := range compatible {
						if pinnedTag.IsUpgradeCompatible(v) {
							contains = true
							upgradeTag = v.String()
							break
						}
					}
					if contains {
						log.Infof("upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString)
					} else {
						log.Infof("service %s, image %s pinned to %s, no compatible upgrade found", service.Name, servicePins[service.Name].image, pinnedTagString)
						continue
					}
				} else {
					log.Fatalf("service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String())
					continue
				}
			} else {
				if bumpType != 0 {
					for _, upTag := range compatible {
						upElement, err := tag.UpgradeDelta(upTag)
						if err != nil {
							return
						}
						delta := upElement.UpgradeType()
						if delta <= bumpType {
							upgradeTag = upTag.String()
							break
						}
					}
					if upgradeTag == "" {
						log.Warnf("not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants", tag.String(), compatible[0].String(), image)
						continue
					}
				} else {
					msg := fmt.Sprintf("upgrade to which tag? (service: %s, image: %s, tag: %s)", service.Name, image, tag)
					if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) || allTags {
						tag := img.(reference.NamedTagged).Tag()
						if !allTags {
							log.Warn(fmt.Sprintf("unable to determine versioning semantics of %s, listing all tags", tag))
						}
						msg = fmt.Sprintf("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
						compatibleStrings = []string{"skip"}
						for _, regVersion := range regVersions {
							compatibleStrings = append(compatibleStrings, regVersion)
						}
					}

					// there is always at least the item "skip" in compatibleStrings (a list of
					// possible upgradable tags) and at least one other tag.
					upgradableTags := compatibleStrings[1:]
					upgrade := anUpgrade{
						Service:     service.Name,
						Image:       image,
						Tag:         tag.String(),
						UpgradeTags: make([]string, len(upgradableTags)),
					}

					for n, s := range upgradableTags {
						var sb strings.Builder
						if _, err := sb.WriteString(s); err != nil {
						}
						upgrade.UpgradeTags[n] = sb.String()
					}

					upgradeList[upgrade.Service] = upgrade

					if internal.NoInput {
						upgradeTag = "skip"
					} else {
						prompt := &survey.Select{
							Message: msg,
							Help:    "enter / return to confirm, choose 'skip' to not upgrade this tag, vim mode is enabled",
							VimMode: true,
							Options: compatibleStrings,
						}
						if err := survey.AskOne(prompt, &upgradeTag); err != nil {
							log.Fatal(err)
						}
					}
				}
			}
			if upgradeTag != "skip" {
				ok, err := recipe.UpdateTag(image, upgradeTag)
				if err != nil {
					log.Fatal(err)
				}
				if ok {
					log.Infof("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image)
				}
			} else {
				if !internal.NoInput {
					log.Warnf("not upgrading %s, skipping as requested", image)
				}
			}
		}

		if internal.NoInput {
			if internal.MachineReadable {
				jsonstring, err := json.Marshal(upgradeList)
				if err != nil {
					log.Fatal(err)
				}

				fmt.Println(string(jsonstring))

				return
			}

			for _, upgrade := range upgradeList {
				log.Infof("can upgrade service: %s, image: %s, tag: %s ::", upgrade.Service, upgrade.Image, upgrade.Tag)
				for _, utag := range upgrade.UpgradeTags {
					log.Infof("    %s", utag)
				}
			}
		}

		isClean, err := gitPkg.IsClean(recipe.Dir)
		if err != nil {
			log.Fatal(err)
		}
		if !isClean {
			log.Infof("%s currently has these unstaged changes 👇", recipe.Name)
			if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil {
				log.Fatal(err)
			}
		}
	},
}

var (
	allTags bool
)

func init() {
	RecipeUpgradeCommand.Flags().BoolVarP(
		&internal.Major,
		"major",
		"x",
		false,
		"increase the major part of the version",
	)

	RecipeUpgradeCommand.Flags().BoolVarP(
		&internal.Minor,
		"minor",
		"y",
		false,
		"increase the minor part of the version",
	)

	RecipeUpgradeCommand.Flags().BoolVarP(
		&internal.Patch,
		"patch",
		"z",
		false,
		"increase the patch part of the version",
	)

	RecipeUpgradeCommand.Flags().BoolVarP(
		&internal.MachineReadable,
		"machine",
		"m",
		false,
		"print machine-readable output",
	)

	RecipeUpgradeCommand.Flags().BoolVarP(
		&allTags,
		"all-tags",
		"a",
		false,
		"list all tags, not just upgrades",
	)
}
