// Copyright 2023 Harness, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package git

import (
	"context"
	"fmt"
	"strings"
	"time"

	"github.com/harness/gitness/errors"
	"github.com/harness/gitness/git/api"
	"github.com/harness/gitness/git/hook"
	"github.com/harness/gitness/git/sha"
	"github.com/harness/gitness/git/sharedrepo"
)

const (
	filePermissionDefault = "100644"
)

type FileAction string

const (
	CreateAction    FileAction = "CREATE"
	UpdateAction    FileAction = "UPDATE"
	DeleteAction    FileAction = "DELETE"
	MoveAction      FileAction = "MOVE"
	PatchTextAction FileAction = "PATCH_TEXT"
)

func (FileAction) Enum() []interface{} {
	return []interface{}{CreateAction, UpdateAction, DeleteAction, MoveAction, PatchTextAction}
}

// CommitFileAction holds file operation data.
type CommitFileAction struct {
	Action  FileAction
	Path    string
	Payload []byte
	SHA     sha.SHA
}

// CommitFilesParams holds the data for file operations.
type CommitFilesParams struct {
	WriteParams
	Title     string
	Message   string
	Branch    string
	NewBranch string
	Actions   []CommitFileAction

	// Committer overwrites the git committer used for committing the files
	// (optional, default: actor)
	Committer *Identity
	// CommitterDate overwrites the git committer date used for committing the files
	// (optional, default: current time on server)
	CommitterDate *time.Time
	// Author overwrites the git author used for committing the files
	// (optional, default: committer)
	Author *Identity
	// AuthorDate overwrites the git author date used for committing the files
	// (optional, default: committer date)
	AuthorDate *time.Time
}

func (p *CommitFilesParams) Validate() error {
	return p.WriteParams.Validate()
}

type CommitFilesResponse struct {
	CommitID sha.SHA
}

//nolint:gocognit,nestif
func (s *Service) CommitFiles(ctx context.Context, params *CommitFilesParams) (CommitFilesResponse, error) {
	if err := params.Validate(); err != nil {
		return CommitFilesResponse{}, err
	}

	committer := params.Actor
	if params.Committer != nil {
		committer = *params.Committer
	}
	committerDate := time.Now().UTC()
	if params.CommitterDate != nil {
		committerDate = *params.CommitterDate
	}

	author := committer
	if params.Author != nil {
		author = *params.Author
	}
	authorDate := committerDate
	if params.AuthorDate != nil {
		authorDate = *params.AuthorDate
	}

	repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID)

	// check if repo is empty

	// IMPORTANT: we don't use gitea's repo.IsEmpty() as that only checks whether the default branch exists (in HEAD).
	// This can be an issue in case someone created a branch already in the repo (just default branch is missing).
	// In that case the user can accidentally create separate git histories (which most likely is unintended).
	// If the user wants to actually build a disconnected commit graph they can use the cli.
	isEmpty, err := s.git.HasBranches(ctx, repoPath)
	if err != nil {
		return CommitFilesResponse{}, fmt.Errorf("CommitFiles: failed to determine if repository is empty: %w", err)
	}

	// validate and prepare input

	// ensure input data is valid
	// the commit will be nil for empty repositories
	commit, err := s.validateAndPrepareCommitFilesHeader(ctx, repoPath, isEmpty, params)
	if err != nil {
		return CommitFilesResponse{}, err
	}

	// ref updater
	var refOldSHA sha.SHA
	var refNewSHA sha.SHA

	branchRef := api.GetReferenceFromBranchName(params.Branch)
	if params.Branch != params.NewBranch {
		// we are creating a new branch, rather than updating the existing one
		refOldSHA = sha.Nil
		branchRef = api.GetReferenceFromBranchName(params.NewBranch)
	} else if commit != nil {
		refOldSHA = commit.SHA
	}

	refUpdater, err := hook.CreateRefUpdater(s.hookClientFactory, params.EnvVars, repoPath, branchRef)

	// run the actions in a shared repo

	err = sharedrepo.Run(ctx, refUpdater, s.tmpDir, repoPath, func(r *sharedrepo.SharedRepo) error {
		var parentCommits []sha.SHA
		var oldTreeSHA sha.SHA

		if isEmpty {
			oldTreeSHA = sha.EmptyTree
			err = s.prepareTreeEmptyRepo(ctx, r, params.Actions)
			if err != nil {
				return fmt.Errorf("failed to prepare empty tree: %w", err)
			}
		} else {
			parentCommits = append(parentCommits, commit.SHA)

			// get tree sha
			rootNode, err := s.git.GetTreeNode(ctx, repoPath, commit.SHA.String(), "")
			if err != nil {
				return fmt.Errorf("CommitFiles: failed to get original node: %w", err)
			}
			oldTreeSHA = rootNode.SHA

			err = r.SetIndex(ctx, commit.SHA)
			if err != nil {
				return fmt.Errorf("failed to set index in shared repository: %w", err)
			}

			err = s.prepareTree(ctx, r, commit.SHA, params.Actions)
			if err != nil {
				return fmt.Errorf("failed to prepare tree: %w", err)
			}
		}

		treeSHA, err := r.WriteTree(ctx)
		if err != nil {
			return fmt.Errorf("failed to write tree object: %w", err)
		}

		if oldTreeSHA.Equal(treeSHA) {
			return errors.InvalidArgument("No effective changes.")
		}

		message := strings.TrimSpace(params.Title)
		if len(params.Message) > 0 {
			message += "\n\n" + strings.TrimSpace(params.Message)
		}

		authorSig := &api.Signature{
			Identity: api.Identity{
				Name:  author.Name,
				Email: author.Email,
			},
			When: authorDate,
		}

		committerSig := &api.Signature{
			Identity: api.Identity{
				Name:  committer.Name,
				Email: committer.Email,
			},
			When: committerDate,
		}

		commitSHA, err := r.CommitTree(ctx, authorSig, committerSig, treeSHA, message, false, parentCommits...)
		if err != nil {
			return fmt.Errorf("failed to commit the tree: %w", err)
		}

		refNewSHA = commitSHA

		if err := refUpdater.Init(ctx, refOldSHA, refNewSHA); err != nil {
			return fmt.Errorf("failed to init ref updater old=%s new=%s: %w", refOldSHA, refNewSHA, err)
		}

		return nil
	})
	if err != nil {
		return CommitFilesResponse{}, fmt.Errorf("CommitFiles: failed to create commit in shared repository: %w", err)
	}

	// get commit

	commit, err = s.git.GetCommit(ctx, repoPath, refNewSHA.String())
	if err != nil {
		return CommitFilesResponse{}, fmt.Errorf("failed to get commit for SHA %s: %w",
			refNewSHA.String(), err)
	}

	return CommitFilesResponse{
		CommitID: commit.SHA,
	}, nil
}

func (s *Service) prepareTree(
	ctx context.Context,
	r *sharedrepo.SharedRepo,
	treeishSHA sha.SHA,
	actions []CommitFileAction,
) error {
	// patch file actions are executed in batch for a single file
	patchMap := map[string][]*CommitFileAction{}

	// keep track of what paths have been written to detect conflicting actions
	modifiedPaths := map[string]bool{}

	for i := range actions {
		act := &actions[i]

		// patch text actions are executed in per-file batches.
		if act.Action == PatchTextAction {
			patchMap[act.Path] = append(patchMap[act.Path], act)
			continue
		}
		// anything else is executed as is
		modifiedPath, err := s.processAction(ctx, r, treeishSHA, act)
		if err != nil {
			return fmt.Errorf("failed to process action %s on %q: %w", act.Action, act.Path, err)
		}

		if modifiedPaths[modifiedPath] {
			return errors.InvalidArgument("More than one conflicting actions are modifying file %q.", modifiedPath)
		}
		modifiedPaths[modifiedPath] = true
	}

	for filePath, patchActions := range patchMap {
		// combine input across actions
		var fileSHA sha.SHA
		var payloads [][]byte
		for _, act := range patchActions {
			payloads = append(payloads, act.Payload)
			if fileSHA.IsEmpty() {
				fileSHA = act.SHA
				continue
			}

			// there can only be one file sha for a given path and commit.
			if !act.SHA.IsEmpty() && !fileSHA.Equal(act.SHA) {
				return errors.InvalidArgument(
					"patch text actions for %q contain different SHAs %q and %q",
					filePath,
					act.SHA,
					fileSHA,
				)
			}
		}

		if err := r.PatchTextFile(ctx, treeishSHA, filePath, fileSHA, payloads); err != nil {
			return fmt.Errorf("failed to process action %s on %q: %w", PatchTextAction, filePath, err)
		}

		if modifiedPaths[filePath] {
			return errors.InvalidArgument("More than one conflicting action are modifying file %q.", filePath)
		}
		modifiedPaths[filePath] = true
	}

	return nil
}

func (s *Service) prepareTreeEmptyRepo(
	ctx context.Context,
	r *sharedrepo.SharedRepo,
	actions []CommitFileAction,
) error {
	for _, action := range actions {
		if action.Action != CreateAction {
			return errors.PreconditionFailed("action not allowed on empty repository")
		}

		filePath := api.CleanUploadFileName(action.Path)
		if filePath == "" {
			return errors.InvalidArgument("invalid path")
		}

		if err := r.CreateFile(ctx, sha.None, filePath, filePermissionDefault, action.Payload); err != nil {
			return errors.Internal(err, "failed to create file '%s'", action.Path)
		}
	}

	return nil
}

func (s *Service) validateAndPrepareCommitFilesHeader(
	ctx context.Context,
	repoPath string,
	isEmpty bool,
	params *CommitFilesParams,
) (*api.Commit, error) {
	if params.Branch == "" {
		defaultBranchRef, err := s.git.GetDefaultBranch(ctx, repoPath)
		if err != nil {
			return nil, fmt.Errorf("failed to get default branch: %w", err)
		}
		params.Branch = defaultBranchRef
	}

	if params.NewBranch == "" {
		params.NewBranch = params.Branch
	}

	// trim refs/heads/ prefixes to avoid issues when calling gitea API
	params.Branch = strings.TrimPrefix(strings.TrimSpace(params.Branch), gitReferenceNamePrefixBranch)
	params.NewBranch = strings.TrimPrefix(strings.TrimSpace(params.NewBranch), gitReferenceNamePrefixBranch)

	// if the repo is empty then we can skip branch existence checks
	if isEmpty {
		return nil, nil //nolint:nilnil // an empty repository has no commit and there's no error
	}

	// ensure source branch exists
	branch, err := s.git.GetBranch(ctx, repoPath, params.Branch)
	if err != nil {
		return nil, fmt.Errorf("failed to get source branch '%s': %w", params.Branch, err)
	}

	// ensure new branch doesn't exist yet (if new branch creation was requested)
	if params.Branch != params.NewBranch {
		existingBranch, err := s.git.GetBranch(ctx, repoPath, params.NewBranch)
		if existingBranch != nil {
			return nil, errors.Conflict("branch %s already exists", existingBranch.Name)
		}
		if err != nil && !errors.IsNotFound(err) {
			return nil, fmt.Errorf("failed to create new branch '%s': %w", params.NewBranch, err)
		}
	}

	return branch.Commit, nil
}

func (s *Service) processAction(
	ctx context.Context,
	r *sharedrepo.SharedRepo,
	treeishSHA sha.SHA,
	action *CommitFileAction,
) (modifiedPath string, err error) {
	filePath := api.CleanUploadFileName(action.Path)
	if filePath == "" {
		return "", errors.InvalidArgument("path cannot be empty")
	}
	modifiedPath = filePath
	switch action.Action {
	case CreateAction:
		err = r.CreateFile(ctx, treeishSHA, filePath, filePermissionDefault, action.Payload)
	case UpdateAction:
		err = r.UpdateFile(ctx, treeishSHA, filePath, action.SHA, filePermissionDefault, action.Payload)
	case MoveAction:
		modifiedPath, err = r.MoveFile(ctx, treeishSHA, filePath, action.SHA, filePermissionDefault, action.Payload)
	case DeleteAction:
		err = r.DeleteFile(ctx, filePath)
	case PatchTextAction:
		return "", fmt.Errorf("action %s not supported by this method", action.Action)
	default:
		err = fmt.Errorf("unknown file action %q", action.Action)
	}

	return modifiedPath, err
}

/*
func (s *Service) prepareTree(
	ctx context.Context,
	shared *api.SharedRepo,
	actions []CommitFileAction,
	commit *api.Commit,
) error {
	// execute all actions
	for i := range actions {
		if err := s.processAction(ctx, shared, &actions[i], commit); err != nil {
			return err
		}
	}

	return nil
}

func prepareTreeEmptyRepo(
	ctx context.Context,
	shared *api.SharedRepo,
	actions []CommitFileAction,
) error {
	for _, action := range actions {
		if action.Action != CreateAction {
			return errors.PreconditionFailed("action not allowed on empty repository")
		}

		filePath := api.CleanUploadFileName(action.Path)
		if filePath == "" {
			return errors.InvalidArgument("invalid path")
		}

		if err := createFile(ctx, shared, nil, filePath, defaultFilePermission, action.Payload); err != nil {
			return errors.Internal(err, "failed to create file '%s'", action.Path)
		}
	}

	return nil
}

func (s *Service) processAction(
	ctx context.Context,
	shared *api.SharedRepo,
	action *CommitFileAction,
	commit *api.Commit,
) (err error) {
	filePath := api.CleanUploadFileName(action.Path)
	if filePath == "" {
		return errors.InvalidArgument("path cannot be empty")
	}

	switch action.Action {
	case CreateAction:
		err = createFile(ctx, shared, commit, filePath, defaultFilePermission, action.Payload)
	case UpdateAction:
		err = updateFile(ctx, shared, commit, filePath, action.SHA, defaultFilePermission, action.Payload)
	case MoveAction:
		err = moveFile(ctx, shared, commit, filePath, action.SHA, defaultFilePermission, action.Payload)
	case DeleteAction:
		err = deleteFile(ctx, shared, filePath)
	}

	return err
}

func createFile(ctx context.Context, repo *api.SharedRepo, commit *api.Commit,
	filePath, mode string, payload []byte) error {
	// only check path availability if a source commit is available (empty repo won't have such a commit)
	if commit != nil {
		if err := checkPathAvailability(ctx, repo, commit, filePath, true); err != nil {
			return err
		}
	}

	hash, err := repo.WriteGitObject(ctx, bytes.NewReader(payload))
	if err != nil {
		return fmt.Errorf("createFile: error hashing object: %w", err)
	}

	// Add the object to the index
	if err = repo.AddObjectToIndex(ctx, mode, hash.String(), filePath); err != nil {
		return fmt.Errorf("createFile: error creating object: %w", err)
	}
	return nil
}

func updateFile(
	ctx context.Context,
	repo *api.SharedRepo,
	commit *api.Commit,
	filePath string,
	sha string,
	mode string,
	payload []byte,
) error {
	// get file mode from existing file (default unless executable)
	entry, err := getFileEntry(ctx, repo, commit, sha, filePath)
	if err != nil {
		return err
	}
	if entry.IsExecutable() {
		mode = "100755"
	}

	hash, err := repo.WriteGitObject(ctx, bytes.NewReader(payload))
	if err != nil {
		return fmt.Errorf("updateFile: error hashing object: %w", err)
	}

	if err = repo.AddObjectToIndex(ctx, mode, hash.String(), filePath); err != nil {
		return fmt.Errorf("updateFile: error updating object: %w", err)
	}
	return nil
}

func moveFile(
	ctx context.Context,
	repo *api.SharedRepo,
	commit *api.Commit,
	filePath string,
	sha string,
	mode string,
	payload []byte,
) error {
	newPath, newContent, err := parseMovePayload(payload)
	if err != nil {
		return err
	}

	// ensure file exists and matches SHA
	entry, err := getFileEntry(ctx, repo, commit, sha, filePath)
	if err != nil {
		return err
	}

	// ensure new path is available
	if err = checkPathAvailability(ctx, repo, commit, newPath, false); err != nil {
		return err
	}

	var fileHash string
	var fileMode string
	if newContent != nil {
		hash, err := repo.WriteGitObject(ctx, bytes.NewReader(newContent))
		if err != nil {
			return fmt.Errorf("moveFile: error hashing object: %w", err)
		}

		fileHash = hash.String()
		fileMode = mode
		if entry.IsExecutable() {
			fileMode = "100755"
		}
	} else {
		fileHash = entry.SHA.String()
		fileMode = entry.Mode.String()
	}

	if err = repo.AddObjectToIndex(ctx, fileMode, fileHash, newPath); err != nil {
		return fmt.Errorf("moveFile: add object error: %w", err)
	}

	if err = repo.RemoveFilesFromIndex(ctx, filePath); err != nil {
		return fmt.Errorf("moveFile: remove object error: %w", err)
	}
	return nil
}

func deleteFile(ctx context.Context, repo *api.SharedRepo, filePath string) error {
	filesInIndex, err := repo.LsFiles(ctx, filePath)
	if err != nil {
		return fmt.Errorf("deleteFile: listing files error: %w", err)
	}
	if !slices.Contains(filesInIndex, filePath) {
		return errors.NotFound("file path %s not found", filePath)
	}

	if err = repo.RemoveFilesFromIndex(ctx, filePath); err != nil {
		return fmt.Errorf("deleteFile: remove object error: %w", err)
	}
	return nil
}

func getFileEntry(
	ctx context.Context,
	repo *api.SharedRepo,
	commit *api.Commit,
	sha string,
	path string,
) (*api.TreeNode, error) {
	entry, err := repo.GetTreeNode(ctx, commit.SHA.String(), path)
	if errors.IsNotFound(err) {
		return nil, errors.NotFound("path %s not found", path)
	}
	if err != nil {
		return nil, fmt.Errorf("getFileEntry: failed to get tree for path %s: %w", path, err)
	}

	// If a SHA was given and the SHA given doesn't match the SHA of the fromTreePath, throw error
	if sha != "" && sha != entry.SHA.String() {
		return nil, errors.InvalidArgument("sha does not match for path %s [given: %s, expected: %s]",
			path, sha, entry.SHA)
	}

	return entry, nil
}

// checkPathAvailability ensures that the path is available for the requested operation.
// For the path where this file will be created/updated, we need to make
// sure no parts of the path are existing files or links except for the last
// item in the path which is the file name, and that shouldn't exist IF it is
// a new file OR is being moved to a new path.
func checkPathAvailability(
	ctx context.Context,
	repo *api.SharedRepo,
	commit *api.Commit,
	filePath string,
	isNewFile bool,
) error {
	parts := strings.Split(filePath, "/")
	subTreePath := ""
	for index, part := range parts {
		subTreePath = path.Join(subTreePath, part)
		entry, err := repo.GetTreeNode(ctx, commit.SHA.String(), subTreePath)
		if err != nil {
			if errors.IsNotFound(err) {
				// Means there is no item with that name, so we're good
				break
			}
			return fmt.Errorf("checkPathAvailability: failed to get tree entry for path %s: %w", subTreePath, err)
		}
		switch {
		case index < len(parts)-1:
			if !entry.IsDir() {
				return errors.Conflict("a file already exists where you're trying to create a subdirectory [path: %s]",
					subTreePath)
			}
		case entry.IsLink():
			return errors.Conflict("a symbolic link already exist where you're trying to create a subdirectory [path: %s]",
				subTreePath)
		case entry.IsDir():
			return errors.Conflict("a directory already exists where you're trying to create a subdirectory [path: %s]",
				subTreePath)
		case filePath != "" || isNewFile:
			return errors.Conflict("file path %s already exists", filePath)
		}
	}
	return nil
}

func parseMovePayload(payload []byte) (string, []byte, error) {
	var newContent []byte
	var newPath string
	filePathEnd := bytes.IndexByte(payload, 0)
	if filePathEnd < 0 {
		newPath = string(payload)
		newContent = nil
	} else {
		newPath = string(payload[:filePathEnd])
		newContent = payload[filePathEnd+1:]
	}

	newPath = api.CleanUploadFileName(newPath)
	if newPath == "" {
		return "", nil, api.ErrInvalidPath
	}

	return newPath, newContent, nil
}
*/
