package recipe

import (
	"errors"
	"fmt"
	"os"
	"path"
	"strconv"
	"strings"

	"coopcloud.tech/abra/cli/internal"
	"coopcloud.tech/abra/pkg/autocomplete"
	"coopcloud.tech/abra/pkg/formatter"
	gitPkg "coopcloud.tech/abra/pkg/git"
	"coopcloud.tech/abra/pkg/i18n"
	"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/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/spf13/cobra"
)

// Errors
var errEmptyVersionsInCatalogue = errors.New(i18n.G("catalogue versions list is unexpectedly empty"))

// translators: `abra recipe release` aliases. use a comma separated list of
// aliases with no spaces in between
var recipeReleaseAliases = i18n.G("rl")

var RecipeReleaseCommand = &cobra.Command{
	// translators: `recipe release` command
	Use:     i18n.G("release <recipe> [version] [flags]"),
	Aliases: strings.Split(recipeReleaseAliases, ","),
	// translators: Short description for `recipe release` command
	Short: i18n.G("Release a new recipe version"),
	Long: i18n.G(`Create a new version of a recipe.

These versions are then published on the Co-op Cloud recipe catalogue. These
versions take the following form:

    a.b.c+x.y.z

Where the "a.b.c" part is a semantic version determined by the maintainer. The
"x.y.z" part is the image tag of the recipe "app" service (the main container
which contains the software to be used, by naming convention).

We maintain a semantic versioning scheme ("a.b.c") alongside the recipe
versioning scheme ("x.y.z") in order to maximise the chances that the nature of
recipe updates are properly communicated. I.e. developers of an app might
publish a minor version but that might lead to changes in the recipe which are
major and therefore require intervention while doing the upgrade work.

This command will publish your new release to git.coopcloud.tech. This
requires that you have permission to git push to these repositories and have
your SSH keys configured on your account. Enable ssh-agent and make sure to add
your private key and enter your passphrase beforehand.

    eval ` + "`ssh-agent`" + `
    ssh-add ~/.ssh/<my-ssh-private-key-for-git-coopcloud-tech>`),
	Example: `  # publish release
  eval ` + "`ssh-agent`" + `
  ssh-add ~/.ssh/id_ed25519
  abra recipe release gitea`,
	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.RecipeNameComplete()
		case 1:
			return autocomplete.RecipeVersionComplete(args[0])
		default:
			return nil, cobra.ShellCompDirectiveDefault
		}
	},
	Run: func(cmd *cobra.Command, args []string) {
		recipe := internal.ValidateRecipe(args, cmd.Name())

		imagesTmp, err := GetImageVersions(recipe)
		if err != nil {
			log.Fatal(err)
		}

		mainApp, err := internal.GetMainAppImage(recipe)
		if err != nil {
			log.Fatal(err)
		}

		mainAppVersion := imagesTmp[mainApp]
		if mainAppVersion == "" {
			log.Fatal(i18n.G("main app service version for %s is empty?", recipe.Name))
		}

		repo, err := git.PlainOpen(recipe.Dir)
		if err != nil {
			log.Fatal(err)
		}

		preCommitHead, err := repo.Head()
		if err != nil {
			log.Fatal(err)
		}

		isClean, err := gitPkg.IsClean(recipe.Dir)
		if err != nil {
			log.Fatal(err)
		}

		if !isClean {
			log.Fatal(i18n.G("working directory not clean in %s, aborting", recipe.Dir))
		}

		tags, err := recipe.Tags()
		if err != nil {
			log.Fatal(err)
		}

		var tagString string
		if len(args) == 2 {
			tagString = args[1]
		}

		if (internal.Major || internal.Minor || internal.Patch) && tagString != "" {
			log.Fatal(i18n.G("cannot specify tag and bump type at the same time"))
		}

		if len(tags) == 0 && tagString == "" {
			log.Warn(i18n.G("no git tags found for %s", recipe.Name))
			if internal.NoInput {
				log.Fatal(i18n.G("unable to continue, input required for initial version"))
			}
			fmt.Println(i18n.G(`
The following options are two types of initial semantic version that you can
pick for %s that will be published in the recipe catalogue. This follows the
semver convention (more on https://semver.org), here is a short cheatsheet

    0.1.0: development release, still hacking. when you make a major upgrade
           you increment the "y" part (i.e. 0.1.0 -> 0.2.0) and only move to
           using the "x" part when things are stable.

    1.0.0: public release, assumed to be working. you already have a stable
           and reliable deployment of this app and feel relatively confident
           about it.

If you want people to be able alpha test your current config for %s but don't
think it is quite reliable, go with 0.1.0 and people will know that things are
likely to change.

`, recipe.Name, recipe.Name))
			var chosenVersion string
			edPrompt := &survey.Select{
				Message: i18n.G("which version do you want to begin with?"),
				Options: []string{"0.1.0", "1.0.0"},
			}

			if err := survey.AskOne(edPrompt, &chosenVersion); err != nil {
				log.Fatal(err)
			}

			tagString = fmt.Sprintf("%s+%s", chosenVersion, mainAppVersion)
		}

		if tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) {

			catl, err := recipePkg.ReadRecipeCatalogue(false)
			if err != nil {
				log.Fatal(err)
			}

			changesTable, err := formatter.CreateTable()
			if err != nil {
				log.Fatal(err)
			}

			latestRelease := tags[len(tags)-1]
			latestRecipeVersion, err := getLatestVersion(recipe, catl)
			if err != nil && err != errEmptyVersionsInCatalogue {
				log.Fatal(err)
			}
			changesTable.Headers(i18n.G("SERVICE"), latestRelease, i18n.G("PROPOSED CHANGES"))

			allRecipeVersions := catl[recipe.Name].Versions
			for _, recipeVersion := range allRecipeVersions {
				if serviceVersions, ok := recipeVersion[latestRecipeVersion]; ok {
					for serviceName := range serviceVersions {
						serviceMeta := serviceVersions[serviceName]

						existingImageTag := fmt.Sprintf("%s:%s", serviceMeta.Image, serviceMeta.Tag)
						newImageTag := fmt.Sprintf("%s:%s", serviceMeta.Image, imagesTmp[serviceMeta.Image])

						if existingImageTag == newImageTag {
							continue
						}

						changesTable.Row([]string{serviceName, existingImageTag, newImageTag}...)
					}
				}
			}

			changeOverview := changesTable.Render()

			if err := internal.PromptBumpType("", latestRelease, changeOverview); err != nil {
				log.Fatal(err)
			}
		}

		if tagString == "" {
			var lastGitTag tagcmp.Tag
			iter, err := repo.Tags()
			if err != nil {
				log.Fatal(err)
			}

			if err := iter.ForEach(func(ref *plumbing.Reference) error {
				obj, err := repo.TagObject(ref.Hash())
				if err != nil {
					log.Fatal(i18n.G("tag at commit %s is unannotated or otherwise broken", ref.Hash()))
					return err
				}

				tagcmpTag, err := tagcmp.Parse(obj.Name)
				if err != nil {
					return err
				}

				if (lastGitTag == tagcmp.Tag{}) {
					lastGitTag = tagcmpTag
				} else if tagcmpTag.IsGreaterThan(lastGitTag) {
					lastGitTag = tagcmpTag
				}

				return nil
			}); err != nil {
				log.Fatal(err)
			}

			// bumpType is used to decide what part of the tag should be incremented
			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(i18n.G("you can only use one version flag: --major, --minor or --patch"))
				}
			}

			newTag := lastGitTag
			if bumpType > 0 {
				if internal.Patch {
					now, err := strconv.Atoi(newTag.Patch)
					if err != nil {
						log.Fatal(err)
					}

					newTag.Patch = strconv.Itoa(now + 1)
				} else if internal.Minor {
					now, err := strconv.Atoi(newTag.Minor)
					if err != nil {
						log.Fatal(err)
					}

					newTag.Patch = "0"
					newTag.Minor = strconv.Itoa(now + 1)
				} else if internal.Major {
					now, err := strconv.Atoi(newTag.Major)
					if err != nil {
						log.Fatal(err)
					}

					newTag.Patch = "0"
					newTag.Minor = "0"
					newTag.Major = strconv.Itoa(now + 1)
				}
			}

			newTag.Metadata = mainAppVersion
			log.Debug(i18n.G("choosing %s as new version for %s", newTag.String(), recipe.Name))
			tagString = newTag.String()
		}

		if _, err := tagcmp.Parse(tagString); err != nil {
			log.Fatal(i18n.G("invalid version %s specified", tagString))
		}

		for _, tag := range tags {
			previousTagLeftHand := strings.Split(tag, "+")[0]
			newTagStringLeftHand := strings.Split(tagString, "+")[0]
			if previousTagLeftHand == newTagStringLeftHand {
				log.Fatal(i18n.G("%s+... conflicts with a previous release: %s", newTagStringLeftHand, tag))
			}
		}

		if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
			if cleanErr := cleanTag(recipe, tagString); cleanErr != nil {
				log.Fatal(i18n.G("unable to clean up tag after failed release attempt: %s", cleanErr))
			}
			if resetErr := resetCommit(recipe, preCommitHead); resetErr != nil {
				log.Fatal(i18n.G("unable to reset commit after failed release attempt: %s", resetErr))
			}
			log.Error(err)
			log.Fatal(i18n.G("release failed. any changes made have been reverted"))
		}
	},
}

// GetImageVersions retrieves image versions for a recipe
func GetImageVersions(recipe recipePkg.Recipe) (map[string]string, error) {
	services := make(map[string]string)

	config, err := recipe.GetComposeConfig(nil)
	if err != nil {
		return nil, err
	}
	missingTag := false
	for _, service := range config.Services {
		if service.Image == "" {
			continue
		}

		img, err := reference.ParseNormalizedNamed(service.Image)
		if err != nil {
			return services, err
		}

		path := reference.Path(img)

		path = formatter.StripTagMeta(path)

		var tag string
		switch img.(type) {
		case reference.NamedTagged:
			tag = img.(reference.NamedTagged).Tag()
		case reference.Named:
			if service.Name == "app" {
				missingTag = true
			}
			continue
		}

		services[path] = tag
	}

	if missingTag {
		return services, errors.New(i18n.G("app service is missing image tag?"))
	}

	return services, nil
}

// createReleaseFromTag creates a new release based on a supplied recipe version string
func createReleaseFromTag(recipe recipePkg.Recipe, tagString, mainAppVersion string) error {
	var err error

	repo, err := git.PlainOpen(recipe.Dir)
	if err != nil {
		return err
	}

	mainService := "app"
	label := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", tagString)
	if !internal.Dry {
		if err := recipe.UpdateLabel("compose.y*ml", mainService, label); err != nil {
			log.Fatal(err)
		}
	} else {
		log.Info(i18n.G("dry run: not syncing label %s for recipe %s", tagString, recipe.Name))
	}

	if err := addReleaseNotes(recipe, tagString); err != nil {
		return errors.New(i18n.G("failed to add release notes: %s", err.Error()))
	}

	if err := commitRelease(recipe, tagString); err != nil {
		return errors.New(i18n.G("failed to commit changes: %s", err.Error()))
	}

	if err := tagRelease(tagString, repo); err != nil {
		return errors.New(i18n.G("failed to tag release: %s", err.Error()))
	}

	if err := pushRelease(recipe, tagString); err != nil {
		return errors.New(i18n.G("failed to publish new release: %s", err.Error()))
	}

	return nil
}

// btoi converts a boolean value into an integer
func btoi(b bool) int {
	if b {
		return 1
	}

	return 0
}

// getTagCreateOptions constructs git tag create options
func getTagCreateOptions(tag string) (git.CreateTagOptions, error) {
	msg := i18n.G("chore: publish %s release", tag)
	return git.CreateTagOptions{Message: msg}, nil
}

// addReleaseNotes checks if the release/next release note exists and moves the
// file to release/<tag>.
func addReleaseNotes(recipe recipePkg.Recipe, tag string) error {
	releaseDir := path.Join(recipe.Dir, "release")
	if _, err := os.Stat(releaseDir); errors.Is(err, os.ErrNotExist) {
		if err := os.Mkdir(releaseDir, 0755); err != nil {
			return err
		}
	}

	tagReleaseNotePath := path.Join(releaseDir, tag)
	if _, err := os.Stat(tagReleaseNotePath); err == nil {
		// Release note for current tag already exist exists.
		return nil
	} else if !errors.Is(err, os.ErrNotExist) {
		return err
	}

	var addNextAsReleaseNotes bool

	nextReleaseNotePath := path.Join(releaseDir, "next")
	if _, err := os.Stat(nextReleaseNotePath); err == nil {
		// release/next note exists. Move it to release/<tag>
		if internal.Dry {
			log.Debug(i18n.G("dry run: move release note from 'next' to %s", tag))
			return nil
		}

		if !internal.NoInput {
			prompt := &survey.Confirm{
				Message: i18n.G("use release note in release/next?"),
			}

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

			if !addNextAsReleaseNotes {
				return nil
			}
		}

		if err := os.Rename(nextReleaseNotePath, tagReleaseNotePath); err != nil {
			return err
		}

		if err := gitPkg.Add(recipe.Dir, path.Join("release", "next"), internal.Dry); err != nil {
			return err
		}

		if err := gitPkg.Add(recipe.Dir, path.Join("release", tag), internal.Dry); err != nil {
			return err
		}
	} else if !errors.Is(err, os.ErrNotExist) {
		return err
	}

	// NOTE(d1): No release note exists for the current release. Or, we've
	// already used release/next as the release note
	if internal.NoInput || addNextAsReleaseNotes {
		return nil
	}

	prompt := &survey.Input{
		Message: i18n.G("add release note? (leave empty to skip)"),
	}

	var releaseNote string
	if err := survey.AskOne(prompt, &releaseNote); err != nil {
		return err
	}

	if releaseNote == "" {
		return nil
	}

	if err := os.WriteFile(tagReleaseNotePath, []byte(releaseNote), 0o644); err != nil {
		return err
	}

	if err := gitPkg.Add(recipe.Dir, path.Join("release", tag), internal.Dry); err != nil {
		return err
	}

	return nil
}

func commitRelease(recipe recipePkg.Recipe, tag string) error {
	if internal.Dry {
		log.Debug(i18n.G("dry run: no changes committed"))
		return nil
	}

	isClean, err := gitPkg.IsClean(recipe.Dir)
	if err != nil {
		return err
	}

	if isClean {
		if !internal.Dry {
			return errors.New(i18n.G("no changes discovered in %s, nothing to publish?", recipe.Dir))
		}
	}

	msg := fmt.Sprintf("chore: publish %s release", tag)
	if err := gitPkg.Commit(recipe.Dir, msg, internal.Dry); err != nil {
		return err
	}

	return nil
}

func tagRelease(tagString string, repo *git.Repository) error {
	if internal.Dry {
		log.Debug(i18n.G("dry run: no git tag created (%s)", tagString))
		return nil
	}

	head, err := repo.Head()
	if err != nil {
		return err
	}

	createTagOptions, err := getTagCreateOptions(tagString)
	if err != nil {
		return err
	}

	_, err = repo.CreateTag(tagString, head.Hash(), &createTagOptions)
	if err != nil {
		return err
	}

	hash := formatter.SmallSHA(head.Hash().String())
	log.Debug(i18n.G("created tag %s at %s", tagString, hash))

	return nil
}

func pushRelease(recipe recipePkg.Recipe, tagString string) error {
	if internal.Dry {
		log.Info(i18n.G("dry run: no changes published"))
		return nil
	}

	if os.Getenv("SSH_AUTH_SOCK") == "" {
		return errors.New(i18n.G("ssh-agent not found. see \"abra recipe release --help\" and try again"))
	}

	if err := recipe.Push(internal.Dry); err != nil {
		return err
	}

	url := fmt.Sprintf("%s/src/tag/%s", recipe.GitURL, tagString)
	log.Info(i18n.G("new release published: %s", url))

	return nil
}

// resetCommit hard resets to the state before release was started.
// This will only remove changes made by the release process due to requiring
// a clean working directory.
func resetCommit(recipe recipePkg.Recipe, head *plumbing.Reference) error {
	repo, err := git.PlainOpen(recipe.Dir)
	if err != nil {
		return errors.New(i18n.G("unable to open repo in %s: %s", recipe.Dir, err))
	}

	worktree, err := repo.Worktree()
	if err != nil {
		return errors.New(i18n.G("unable to open work tree in %s: %s", recipe.Dir, err))
	}

	opts := &git.ResetOptions{Commit: head.Hash(), Mode: git.HardReset}
	if err := worktree.Reset(opts); err != nil {
		return errors.New(i18n.G("unable to hard reset %s: %s", recipe.Dir, err))
	}

	log.Debug(i18n.G("reset commit to pre-release state"))

	return nil
}

// cleanTag removes a freshly created tag
func cleanTag(recipe recipePkg.Recipe, tag string) error {
	repo, err := git.PlainOpen(recipe.Dir)
	if err != nil {
		return errors.New(i18n.G("unable to open repo in %s: %s", recipe.Dir, err))
	}

	if err := repo.DeleteTag(tag); err != nil {
		if !strings.Contains(err.Error(), "not found") {
			return errors.New(i18n.G("unable to delete tag %s: %s", tag, err))
		}
	}

	log.Debug(i18n.G("removed freshly created tag %s", tag))

	return nil
}

func getLatestVersion(recipe recipePkg.Recipe, catl recipePkg.RecipeCatalogue) (string, error) {
	versions, err := recipePkg.GetRecipeCatalogueVersions(recipe.Name, catl)
	if err != nil {
		return "", err
	}
	if len(versions) > 0 {
		return versions[len(versions)-1], nil
	}
	return "", errEmptyVersionsInCatalogue
}

func init() {
	RecipeReleaseCommand.Flags().BoolVarP(
		&internal.Dry,
		i18n.G("dry-run"),
		i18n.G("r"),
		false,
		i18n.G("report changes that would be made"),
	)

	RecipeReleaseCommand.Flags().BoolVarP(
		&internal.Major,
		i18n.G("major"),
		i18n.G("x"),
		false,
		i18n.G("increase the major part of the version"),
	)

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

	RecipeReleaseCommand.Flags().BoolVarP(
		&internal.Patch,
		i18n.G("patch"),
		i18n.G("z"),
		false,
		i18n.G("increase the patch part of the version"),
	)
}
