package gitops

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/object"
	"github.com/go-git/go-git/v5/plumbing/protocol/packp/capability"
	"github.com/go-git/go-git/v5/plumbing/transport"
	"github.com/go-git/go-git/v5/plumbing/transport/http"
	"github.com/go-git/go-git/v5/plumbing/transport/ssh"
	"github.com/golang-jwt/jwt"
	"github.com/litmuschaos/litmus/chaoscenter/graphql/server/graph/model"
	"github.com/litmuschaos/litmus/chaoscenter/graphql/server/pkg/authorization"
	"github.com/litmuschaos/litmus/chaoscenter/graphql/server/pkg/database/mongodb/gitops"
	log "github.com/sirupsen/logrus"
	ssh2 "golang.org/x/crypto/ssh"
)

// GitConfig structure for the GitOps settings
type GitConfig struct {
	ProjectID     string
	RepositoryURL string
	LocalPath     string
	RemoteName    string
	Branch        string
	LatestCommit  string
	UserName      *string
	Password      *string
	AuthType      model.AuthType
	Token         *string
	SSHPrivateKey *string
}

type GitUser struct {
	username string
	email    string
}

const (
	DefaultPath     = "/tmp/gitops/"
	ProjectDataPath = "litmus"
)

func GitUserFromContext(ctx context.Context) GitUser {
	defaultUser := GitUser{
		username: "gitops@litmus-chaos",
		email:    "gitops@litmus.chaos",
	}
	if ctx == nil {
		return defaultUser
	}
	userClaims := ctx.Value(authorization.UserClaim)
	if userClaims == nil {
		return defaultUser
	}
	if _, ok := userClaims.(jwt.MapClaims)["email"]; !ok {
		return GitUser{
			username: userClaims.(jwt.MapClaims)["username"].(string) + "@litmus-chaos",
			email:    userClaims.(jwt.MapClaims)["username"].(string) + "@litmus-chaos",
		}
	}
	return GitUser{
		username: userClaims.(jwt.MapClaims)["username"].(string) + "@litmus-chaos",
		email:    userClaims.(jwt.MapClaims)["email"].(string),
	}
}

// GetGitOpsConfig is used for constructing the GitConfig from dbSchemaGitOps.GitConfigDB
func GetGitOpsConfig(repoData gitops.GitConfigDB) GitConfig {
	gitConfig := GitConfig{
		ProjectID:     repoData.ProjectID,
		RepositoryURL: repoData.RepositoryURL,
		RemoteName:    "origin",
		Branch:        repoData.Branch,
		LocalPath:     DefaultPath + repoData.ProjectID,
		LatestCommit:  repoData.LatestCommit,
		UserName:      repoData.UserName,
		Password:      repoData.Password,
		AuthType:      repoData.AuthType,
		Token:         repoData.Token,
		SSHPrivateKey: repoData.SSHPrivateKey,
	}

	return gitConfig
}

// setupGitRepo helps clones and sets up the repo for GitOps
func (c GitConfig) setupGitRepo(user GitUser) error {
	projectPath := c.LocalPath + "/" + ProjectDataPath + "/" + c.ProjectID

	// clone repo
	_, err := c.GitClone()
	if err != nil {
		return err
	}

	// check if project dir already present in repo
	exists, err := PathExists(projectPath + "/.info")
	if err != nil {
		return err
	}

	gitInfo := map[string]string{"projectID": c.ProjectID, "revision": "1"}
	if exists {
		data, err := os.ReadFile(projectPath + "/.info")
		if err != nil {
			return errors.New("can't read existing git info file " + err.Error())
		}
		err = json.Unmarshal(data, &gitInfo)
		if err != nil {
			return errors.New("can't read existing git info file " + err.Error())
		}
		newRev, err := strconv.Atoi(gitInfo["revision"])
		if err != nil {
			return errors.New("can't read existing git info file[failed to parse revision] " + err.Error())
		}
		gitInfo["revision"] = strconv.Itoa(newRev + 1)
	} else {
		// create project dir and add config if not already present
		err = os.MkdirAll(projectPath, 0755)
		if err != nil {
			return err
		}
	}
	data, err := json.Marshal(gitInfo)
	if err != nil {
		return err
	}
	err = os.WriteFile(projectPath+"/.info", data, 0644)
	if err != nil {
		return err
	}

	// commit and push
	_, err = c.GitCommit(user, "Setup Litmus Chaos GitOps for Project :"+c.ProjectID, nil)
	if err != nil {
		return err
	}
	return c.GitPush()
}

// GitClone clones the repo
func (c GitConfig) GitClone() (*git.Repository, error) {
	// clean the local path
	err := os.RemoveAll(c.LocalPath)
	if err != nil {
		return nil, err
	}
	auth, err := c.getAuthMethod()
	if err != nil {
		return nil, err
	}

	return git.PlainClone(c.LocalPath, false, &git.CloneOptions{
		Auth:          auth,
		URL:           c.RepositoryURL,
		Progress:      nil,
		ReferenceName: plumbing.NewBranchReferenceName(c.Branch),
		SingleBranch:  true,
	})
}

// getAuthMethod returns the AuthMethod instance required for the current repo access [read/writes]
func (c GitConfig) getAuthMethod() (transport.AuthMethod, error) {

	// Azure DevOps requires the 'multi_ack' and 'multi_ack_detailed' capabilities,
	// which are not fully implemented in the go-git package. By default, these
	// capabilities are included in 'transport.UnsupportedCapabilities'.
	transport.UnsupportedCapabilities = []capability.Capability{
		capability.ThinPack,
	}

	switch c.AuthType {

	case model.AuthTypeToken:
		return &http.BasicAuth{
			Username: "litmus", // this can be anything except an empty string
			Password: *c.Token,
		}, nil

	case model.AuthTypeBasic:
		return &http.BasicAuth{
			Username: *c.UserName,
			Password: *c.Password,
		}, nil

	case model.AuthTypeSSH:
		publicKey, err := ssh.NewPublicKeys("git", []byte(*c.SSHPrivateKey), "")
		if err != nil {
			return nil, err
		}
		publicKey.HostKeyCallback = ssh2.InsecureIgnoreHostKey()
		return publicKey, nil

	case model.AuthTypeNone:
		return nil, nil

	default:
		return nil, errors.New("no Matching Auth Type Found")
	}
}

// UnsafeGitPull executes git pull after a hard reset when uncommitted changes are present in repo. Not safe.
func (c GitConfig) UnsafeGitPull() error {
	cleanStatus, err := c.GitGetStatus()
	if err != nil {
		return err
	}
	log.WithFields(log.Fields{"cleanStatus": cleanStatus}).Info("executed GitGetStatus()... ")
	if !cleanStatus {
		log.Info("resetting Repo...: " + c.ProjectID)
		return c.handlerForDirtyStatus()
	}
	return c.GitPull()
}

// GitGetStatus executes "git get status --porcelain" for the provided Repository Path,
// returns true if the repository is clean
// and false if the repository is dirty
func (c GitConfig) GitGetStatus() (bool, error) {
	_, workTree, err := c.getRepositoryWorktreeReference()
	if err != nil {
		return true, err
	}
	status, err := workTree.Status()
	if err != nil {
		return false, err
	}
	return status.IsClean(), nil
}

// getRepositoryWorktreeReference returns the git.Repository and git.Worktree instances for the repo
func (c GitConfig) getRepositoryWorktreeReference() (*git.Repository, *git.Worktree, error) {
	repo, err := git.PlainOpen(c.LocalPath)
	if err != nil {
		return nil, nil, err
	}
	workTree, err := repo.Worktree()
	if err != nil {
		return nil, nil, err
	}
	return repo, workTree, nil
}

// handlerForDirtyStatus calls relative functions if the GitGetStatus gives a clean status as a result
func (c GitConfig) handlerForDirtyStatus() error {
	if err := c.GitHardReset(); err != nil {
		return err
	}
	return c.GitPull()
}

// GitHardReset executes "git reset --hard HEAD" in provided Repository Path
func (c GitConfig) GitHardReset() error {
	_, workTree, err := c.getRepositoryWorktreeReference()
	if err != nil {
		return err
	}
	if workTree.Reset(&git.ResetOptions{Mode: git.HardReset}) != nil {
		return fmt.Errorf("error in executing Reset: %s", err)
	}
	return nil
}

// GitPull updates the repository in provided Path
func (c GitConfig) GitPull() error {
	_, workTree, err := c.getRepositoryWorktreeReference()
	if err != nil {
		return err
	}
	auth, err := c.getAuthMethod()
	if err != nil {
		return err
	}
	err = workTree.Pull(&git.PullOptions{
		Auth:          auth,
		RemoteName:    c.RemoteName,
		ReferenceName: plumbing.NewBranchReferenceName(c.Branch),
		SingleBranch:  true,
	})
	if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
		return err
	}
	return nil
}

// GitCheckout changes the current active branch to specified branch in GitConfig
func (c GitConfig) GitCheckout() error {
	r, w, err := c.getRepositoryWorktreeReference()
	if err != nil {
		return err
	}
	_, err = r.Storer.Reference(plumbing.NewBranchReferenceName(c.Branch))
	create := true
	log.Error(err)
	if err == nil {
		create = false
	}
	return w.Checkout(&git.CheckoutOptions{
		Branch: plumbing.NewBranchReferenceName(c.Branch),
		Create: create,
	})
}

// GitPush pushes the current changes to remote set in GitConfig, always needs auth credentials
func (c GitConfig) GitPush() error {
	if c.AuthType == model.AuthTypeNone {
		return errors.New("cannot write/push without credentials, auth type = none")
	}

	r, _, err := c.getRepositoryWorktreeReference()
	if err != nil {
		return err
	}
	auth, err := c.getAuthMethod()
	if err != nil {
		return err
	}
	err = r.Push(&git.PushOptions{
		RemoteName: c.RemoteName,
		Auth:       auth,
		Progress:   nil,
	})
	if errors.Is(err, git.NoErrAlreadyUpToDate) {
		return nil
	}
	return err
}

// GitCommit saves the changes in the repo and commits them with the message provided
func (c GitConfig) GitCommit(user GitUser, message string, deleteFile *string) (string, error) {
	_, w, err := c.getRepositoryWorktreeReference()
	if err != nil {
		return "", err
	}
	if deleteFile == nil {
		_, err = w.Add("./litmus/" + c.ProjectID + "/")
		if err != nil {
			return "", err
		}
	} else {
		_, err := w.Remove(*deleteFile)
		if err != nil {
			return "", err
		}
	}
	hash, err := w.Commit(message, &git.CommitOptions{
		Author: &object.Signature{
			Name:  user.username,
			Email: user.email,
			When:  time.Now(),
		},
	})

	if err != nil {
		return "", err
	}
	return hash.String(), nil
}

// GetChanges returns the LatestCommit and list of files changed(since previous LatestCommit) in the project directory mentioned in GitConfig
func (c GitConfig) GetChanges() (string, map[string]int, error) {
	path := ProjectDataPath + "/" + c.ProjectID + "/"

	r, _, err := c.getRepositoryWorktreeReference()
	if err != nil {
		return "", nil, err
	}
	var knownCommit *object.Commit = nil
	if c.LatestCommit != "" {
		knownCommit, err = r.CommitObject(plumbing.NewHash(c.LatestCommit))
		if err != nil {
			return "", nil, err
		}
	}
	visited := map[string]int{}

	commitIter, err := r.Log(&git.LogOptions{
		PathFilter: func(file string) bool {
			if (strings.HasSuffix(path, "/") && strings.HasPrefix(file, path)) || (path == file) {
				visited[file] += 1
				return true
			}
			return false
		},
		Order: git.LogOrderCommitterTime,
	})
	if err != nil {
		return "", nil, errors.New("failed to get commit Iterator :" + err.Error())
	}

	commit, err := commitIter.Next()
	if err != nil && err != io.EOF {
		return "", nil, err
	}
	if commit != nil {
		c.LatestCommit = commit.Hash.String()
	}

	for err != io.EOF && commit != nil {
		if knownCommit != nil {
			ancestor, er := commit.IsAncestor(knownCommit)
			if er != nil {
				return "", nil, er
			}
			if knownCommit.Hash == commit.Hash || ancestor {
				break
			}
		}
		commit, err = commitIter.Next()
		if err != nil && err != io.EOF {
			return "", nil, err
		}
	}

	return c.LatestCommit, visited, nil
}

// GetLatestCommitHash returns the latest commit hash in the local repo for the project directory
func (c GitConfig) GetLatestCommitHash() (string, error) {
	path := ProjectDataPath + "/" + c.ProjectID + "/"
	r, _, err := c.getRepositoryWorktreeReference()
	if err != nil {
		return "", err
	}

	commitIter, err := r.Log(&git.LogOptions{
		PathFilter: func(file string) bool {
			return (strings.HasSuffix(path, "/") && strings.HasPrefix(file, path)) || (path == file)
		},
		Order: git.LogOrderCommitterTime,
	})
	if err != nil {
		return "", errors.New("failed to get latest commit hash :" + err.Error())
	}
	commit, err := commitIter.Next()

	if err != nil {
		return "", errors.New("failed to get latest commit hash:" + err.Error())
	}
	return commit.Hash.String(), nil
}

// SetupGitOps clones and sets up the repo for git ops and returns the LatestCommit
func SetupGitOps(user GitUser, gitConfig GitConfig) (string, error) {
	err := gitConfig.setupGitRepo(user)
	if err != nil {
		return "", err
	}
	commitHash, err := gitConfig.GetLatestCommitHash()
	if err != nil {
		return "", err
	}
	return commitHash, err
}
