package recipe

import (
	"errors"
	"fmt"
	"os"
	"slices"
	"sort"
	"strings"

	"coopcloud.tech/abra/pkg/config"
	"coopcloud.tech/abra/pkg/formatter"
	gitPkg "coopcloud.tech/abra/pkg/git"
	"coopcloud.tech/abra/pkg/i18n"
	"coopcloud.tech/abra/pkg/log"
	"coopcloud.tech/tagcmp"
	"github.com/distribution/reference"
	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
)

type EnsureContext struct {
	Chaos            bool
	Offline          bool
	IgnoreEnvVersion bool
}

// Ensure makes sure the recipe exists, is up to date and has the specific
// version checked out.
func (r Recipe) Ensure(ctx EnsureContext) error {
	if err := r.EnsureExists(); err != nil {
		return err
	}

	// NOTE(d1): if we cannot parse the .env.sample then there is a
	//           fundamental problem which requires solving right now
	if _, err := r.SampleEnv(); err != nil {
		return err
	}

	if ctx.Chaos {
		return nil
	}

	if err := r.EnsureIsClean(); err != nil {
		return err
	}

	if !ctx.Offline {
		if err := r.EnsureUpToDate(); err != nil {
			return err
		}
	}

	if r.EnvVersion != "" && !ctx.IgnoreEnvVersion {
		log.Debug(i18n.G("ensuring env version %s", r.EnvVersion))
		if strings.Contains(r.EnvVersion, "+U") {
			return errors.New(i18n.G(`cannot redeploy previous chaos version (%s), did you mean to use "--chaos"?`))
		}

		if _, err := r.EnsureVersion(r.EnvVersion); err != nil {
			return err
		}

		return nil
	}

	if err := r.EnsureLatest(); err != nil {
		return err
	}

	return nil
}

// EnsureExists ensures that the recipe is locally cloned
func (r Recipe) EnsureExists() error {
	if _, err := os.Stat(r.Dir); os.IsNotExist(err) {
		if err := gitPkg.Clone(r.Dir, r.GitURL); err != nil {
			return err
		}
	}

	if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
		return err
	}

	return nil
}

// IsChaosCommit determines if a version sttring is a chaos commit or not.
func (r Recipe) IsChaosCommit(version string) (bool, error) {
	isChaosCommit := false

	if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
		return isChaosCommit, err
	}

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

	tags, err := repo.Tags()
	if err != nil {
		return isChaosCommit, err
	}

	var tagRef plumbing.ReferenceName
	if err := tags.ForEach(func(ref *plumbing.Reference) (err error) {
		if ref.Name().Short() == version {
			tagRef = ref.Name()
		}
		return nil
	}); err != nil {
		return isChaosCommit, err
	}

	if tagRef.String() == "" {
		isChaosCommit = true
	}

	return isChaosCommit, nil
}

// EnsureVersion checks whether a specific version exists for a recipe.
func (r Recipe) EnsureVersion(version string) (bool, error) {
	isChaosCommit := false

	if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
		return isChaosCommit, err
	}

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

	tags, err := repo.Tags()
	if err != nil {
		return isChaosCommit, err
	}

	var parsedTags []string
	var tagRef plumbing.ReferenceName
	if err := tags.ForEach(func(ref *plumbing.Reference) (err error) {
		parsedTags = append(parsedTags, ref.Name().Short())
		if ref.Name().Short() == version {
			tagRef = ref.Name()
		}
		return nil
	}); err != nil {
		return isChaosCommit, err
	}

	joinedTags := strings.Join(parsedTags, ", ")
	if joinedTags != "" {
		log.Debug(i18n.G("read %s as tags for recipe %s", joinedTags, r.Name))
	}

	var opts *git.CheckoutOptions
	if tagRef.String() == "" {
		log.Debug(i18n.G("attempting to checkout '%s' as chaos commit", version))

		hash, err := repo.ResolveRevision(plumbing.Revision(version))
		if err != nil {
			log.Fatal(i18n.G("unable to resolve '%s': %s", version, err))
		}

		opts = &git.CheckoutOptions{Hash: *hash, Create: false, Force: true}
		isChaosCommit = true
	} else {
		opts = &git.CheckoutOptions{Branch: tagRef, Create: false, Force: true}
	}

	worktree, err := repo.Worktree()
	if err != nil {
		return isChaosCommit, nil
	}

	if err := worktree.Checkout(opts); err != nil {
		return isChaosCommit, nil
	}

	log.Debug(i18n.G("successfully checked %s out to %s in %s", r.Name, tagRef.Short(), r.Dir))

	return isChaosCommit, nil
}

// EnsureIsClean makes sure that the recipe repository has no unstaged changes.
func (r Recipe) EnsureIsClean() error {
	isClean, err := gitPkg.IsClean(r.Dir)
	if err != nil {
		return errors.New(i18n.G("unable to check git clean status in %s: %s", r.Dir, err))
	}

	if !isClean {
		return errors.New(i18n.G("%s (%s) has locally unstaged changes?", r.Name, r.Dir))
	}

	return nil
}

// EnsureLatest makes sure the latest commit is checked out for the local recipe repository
func (r Recipe) EnsureLatest() error {
	if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
		return err
	}

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

	worktree, err := repo.Worktree()
	if err != nil {
		return err
	}

	branch, err := gitPkg.GetDefaultBranch(repo, r.Dir)
	if err != nil {
		return err
	}

	checkOutOpts := &git.CheckoutOptions{
		Create: false,
		Force:  true,
		Branch: plumbing.ReferenceName(branch),
	}

	if err := worktree.Checkout(checkOutOpts); err != nil {
		log.Debug(i18n.G("failed to check out %s in %s", branch, r.Dir))
		return err
	}

	return nil
}

// EnsureUpToDate ensures that the local repo is synced to the remote
func (r Recipe) EnsureUpToDate() error {
	repo, err := git.PlainOpen(r.Dir)
	if err != nil {
		return errors.New(i18n.G("unable to open %s: %s", r.Dir, err))
	}

	remotes, err := repo.Remotes()
	if err != nil {
		return errors.New(i18n.G("unable to read remotes in %s: %s", r.Dir, err))
	}

	if len(remotes) == 0 {
		log.Debug(i18n.G("cannot ensure %s is up-to-date, no git remotes configured", r.Name))
		return nil
	}

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

	branch, err := gitPkg.CheckoutDefaultBranch(repo, r.Dir)
	if err != nil {
		return errors.New(i18n.G("unable to check out default branch in %s: %s", r.Dir, err))
	}

	fetchOpts := &git.FetchOptions{Tags: git.AllTags}
	if err := repo.Fetch(fetchOpts); err != nil {
		if !strings.Contains(err.Error(), "already up-to-date") {
			return errors.New(i18n.G("unable to fetch tags in %s: %s", r.Dir, err))
		}
	}

	opts := &git.PullOptions{
		Force:         true,
		ReferenceName: branch,
		SingleBranch:  true,
	}

	if err := worktree.Pull(opts); err != nil {
		if !strings.Contains(err.Error(), "already up-to-date") {
			return errors.New(i18n.G("unable to git pull in %s: %s", r.Dir, err))
		}
	}

	log.Debug(i18n.G("fetched latest git changes for %s", r.Name))

	return nil
}

// IsDirty checks whether a recipe is dirty or not.
func (r *Recipe) IsDirty() (bool, error) {
	isClean, err := gitPkg.IsClean(r.Dir)
	if err != nil {
		return false, err
	}

	return !isClean, nil
}

// ChaosVersion constructs a chaos mode recipe version.
func (r *Recipe) ChaosVersion() (string, error) {
	var version string

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

	version = formatter.SmallSHA(head.String())

	dirty, err := r.IsDirty()
	if err != nil {
		return "", err
	}
	if dirty {
		return fmt.Sprintf("%s%s", version, config.DIRTY_DEFAULT), nil
	}

	return version, nil
}

// Push pushes the latest changes to a SSH URL remote. You need to have your
// local SSH configuration for git.coopcloud.tech working for this to work
func (r Recipe) Push(dryRun bool) error {
	repo, err := git.PlainOpen(r.Dir)
	if err != nil {
		return err
	}

	if err := gitPkg.CreateRemote(repo, "origin-ssh", r.SSHURL, dryRun); err != nil {
		return err
	}

	if err := gitPkg.Push(r.Dir, "origin-ssh", true, dryRun); err != nil {
		return err
	}

	return nil
}

// Tags list the recipe tags
func (r Recipe) Tags() ([]string, error) {
	var tags []string

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

	gitTags, err := repo.Tags()
	if err != nil {
		return tags, err
	}

	if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) {
		tags = append(tags, strings.TrimPrefix(string(ref.Name()), "refs/tags/"))
		return nil
	}); err != nil {
		return tags, err
	}

	sort.Slice(tags, func(i, j int) bool {
		version1, err := tagcmp.Parse(tags[i])
		if err != nil {
			return false
		}
		version2, err := tagcmp.Parse(tags[j])
		if err != nil {
			return false
		}
		return version1.IsLessThan(version2)
	})

	log.Debug(i18n.G("detected %s as tags for recipe %s", strings.Join(tags, ", "), r.Name))

	return tags, nil
}

// GetRecipeVersions retrieves all recipe versions.
func (r Recipe) GetRecipeVersions() (RecipeVersions, []string, error) {
	var warnMsg []string

	versions := RecipeVersions{}

	log.Debug(i18n.G("git: opening repository in %s", r.Dir))

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

	worktree, err := repo.Worktree()
	if err != nil {
		return versions, warnMsg, nil
	}

	gitTags, err := repo.Tags()
	if err != nil {
		return versions, warnMsg, nil
	}

	if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) {
		tag := strings.TrimPrefix(string(ref.Name()), "refs/tags/")

		log.Debug(i18n.G("processing %s for %s", tag, r.Name))

		checkOutOpts := &git.CheckoutOptions{
			Create: false,
			Force:  true,
			Branch: plumbing.ReferenceName(ref.Name()),
		}
		if err := worktree.Checkout(checkOutOpts); err != nil {
			log.Debug(i18n.G("failed to check out %s in %s: %s", tag, r.Dir, err))
			warnMsg = append(warnMsg, i18n.G("skipping tag %s: checkout failed: %s", tag, err))
			return nil
		}

		log.Debug(i18n.G("git checkout: %s in %s", ref.Name(), r.Dir))

		config, err := r.GetComposeConfig(nil)
		if err != nil {
			log.Debug(i18n.G("failed to get compose config for %s: %s", tag, err))
			warnMsg = append(warnMsg, i18n.G("skipping tag %s: invalid compose config: %s", tag, err))
			return nil
		}

		versionMeta := make(map[string]ServiceMeta)
		for _, service := range config.Services {

			img, err := reference.ParseNormalizedNamed(service.Image)
			if err != nil {
				log.Debug(i18n.G("failed to parse image for %s in %s: %s", service.Name, tag, err))
				warnMsg = append(warnMsg, i18n.G("skipping tag %s: invalid image reference in service %s: %s", tag, service.Name, err))
				return nil
			}

			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:
				warnMsg = append(warnMsg, i18n.G("%s service is missing image tag?", path))
				continue
			}

			versionMeta[service.Name] = ServiceMeta{
				Image: path,
				Tag:   tag,
			}
		}

		versions = append(versions, map[string]map[string]ServiceMeta{tag: versionMeta})

		return nil
	}); err != nil {
		log.Warn(i18n.G("GetRecipeVersions encountered error for %s: %s (collected %d versions)", r.Name, err, len(versions)))
		return versions, warnMsg, nil
	}

	_, err = gitPkg.CheckoutDefaultBranch(repo, r.Dir)
	if err != nil {
		return versions, warnMsg, nil
	}

	sortRecipeVersions(versions)

	log.Debug(i18n.G("collected %s for %s", versions, r.Dir))

	var uniqueWarnings []string
	for _, w := range warnMsg {
		if !slices.Contains(uniqueWarnings, w) {
			uniqueWarnings = append(uniqueWarnings, w)
		}
	}

	return versions, uniqueWarnings, nil
}

// Head retrieves latest HEAD metadata.
func (r Recipe) Head() (*plumbing.Reference, error) {
	repo, err := git.PlainOpen(r.Dir)
	if err != nil {
		return nil, err
	}

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

	return head, nil
}
