// 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/enum"
	"github.com/harness/gitness/git/hook"
	"github.com/harness/gitness/git/merge"
	"github.com/harness/gitness/git/sha"

	"github.com/rs/zerolog/log"
)

// MergeParams is input structure object for merging operation.
type MergeParams struct {
	WriteParams
	BaseBranch string
	// HeadRepoUID specifies the UID of the repo that contains the head branch (required for forking).
	// WARNING: This field is currently not supported yet!
	HeadRepoUID string
	HeadBranch  string
	Title       string
	Message     string

	// 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

	RefType enum.RefType
	RefName string

	// HeadExpectedSHA is commit sha on the head branch, if HeadExpectedSHA is older
	// than the HeadBranch latest sha then merge will fail.
	HeadExpectedSHA sha.SHA

	Force            bool
	DeleteHeadBranch bool

	Method enum.MergeMethod
}

func (p *MergeParams) Validate() error {
	if err := p.WriteParams.Validate(); err != nil {
		return err
	}

	if p.BaseBranch == "" {
		return errors.InvalidArgument("base branch is mandatory")
	}

	if p.HeadBranch == "" {
		return errors.InvalidArgument("head branch is mandatory")
	}

	if p.RefType != enum.RefTypeUndefined && p.RefName == "" {
		return errors.InvalidArgument("ref name has to be provided if type is defined")
	}
	return nil
}

// MergeOutput is result object from merging and returns
// base, head and commit sha.
type MergeOutput struct {
	// BaseSHA is the sha of the latest commit on the base branch that was used for merging.
	BaseSHA sha.SHA
	// HeadSHA is the sha of the latest commit on the head branch that was used for merging.
	HeadSHA sha.SHA
	// MergeBaseSHA is the sha of the merge base of the HeadSHA and BaseSHA
	MergeBaseSHA sha.SHA
	// MergeSHA is the sha of the commit after merging HeadSHA with BaseSHA.
	MergeSHA sha.SHA

	CommitCount      int
	ChangedFileCount int
	ConflictFiles    []string
}

// Merge method executes git merge operation. Refs can be sha, branch or tag.
// Based on input params.RefType merge can do checking or final merging of two refs.
// some examples:
//
//	params.RefType = Undefined -> discard merge commit (only performs a merge check).
//	params.RefType = Raw and params.RefName = refs/pull/1/ref will push to refs/pullreq/1/ref
//	params.RefType = RefTypeBranch and params.RefName = "somebranch" -> merge and push to refs/heads/somebranch
//	params.RefType = RefTypePullReqHead and params.RefName = "1" -> merge and push to refs/pullreq/1/head
//	params.RefType = RefTypePullReqMerge and params.RefName = "1" -> merge and push to refs/pullreq/1/merge
//
// There are cases when you want to block merging and for that you will need to provide
// params.HeadExpectedSHA which will be compared with the latest sha from head branch
// if they are not the same error will be returned.
//
//nolint:gocognit,gocyclo,cyclop
func (s *Service) Merge(ctx context.Context, params *MergeParams) (MergeOutput, error) {
	err := params.Validate()
	if err != nil {
		return MergeOutput{}, fmt.Errorf("params not valid: %w", err)
	}

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

	// prepare the merge method function

	mergeMethod, ok := params.Method.Sanitize()
	if !ok && params.Method != "" {
		return MergeOutput{}, errors.InvalidArgument("Unsupported merge method: %s", params.Method)
	}

	var mergeFunc merge.Func

	switch mergeMethod {
	case enum.MergeMethodMerge:
		mergeFunc = merge.Merge
	case enum.MergeMethodSquash:
		mergeFunc = merge.Squash
	case enum.MergeMethodRebase:
		mergeFunc = merge.Rebase
	default:
		// should not happen, the call to Sanitize above should handle this case.
		panic("unsupported merge method")
	}

	// set up the target reference

	var refPath string
	var refOldValue sha.SHA

	if params.RefType != enum.RefTypeUndefined {
		refPath, err = GetRefPath(params.RefName, params.RefType)
		if err != nil {
			return MergeOutput{}, fmt.Errorf(
				"failed to generate full reference for type '%s' and name '%s' for merge operation: %w",
				params.RefType, params.RefName, err)
		}

		refOldValue, err = s.git.GetFullCommitID(ctx, repoPath, refPath)
		if errors.IsNotFound(err) {
			refOldValue = sha.Nil
		} else if err != nil {
			return MergeOutput{}, fmt.Errorf("failed to resolve %q: %w", refPath, err)
		}
	}

	// logger

	log := log.Ctx(ctx).With().
		Str("repo_uid", params.RepoUID).
		Str("head", params.HeadBranch).
		Str("base", params.BaseBranch).
		Str("method", string(mergeMethod)).
		Str("ref", refPath).
		Logger()

	// find the commit SHAs

	baseCommitSHA, err := s.git.GetFullCommitID(ctx, repoPath, params.BaseBranch)
	if err != nil {
		return MergeOutput{}, fmt.Errorf("failed to get merge base branch commit SHA: %w", err)
	}

	headCommitSHA, err := s.git.GetFullCommitID(ctx, repoPath, params.HeadBranch)
	if err != nil {
		return MergeOutput{}, fmt.Errorf("failed to get merge base branch commit SHA: %w", err)
	}

	if !params.HeadExpectedSHA.IsEmpty() && !params.HeadExpectedSHA.Equal(headCommitSHA) {
		return MergeOutput{}, errors.PreconditionFailed(
			"head branch '%s' is on SHA '%s' which doesn't match expected SHA '%s'.",
			params.HeadBranch,
			headCommitSHA,
			params.HeadExpectedSHA)
	}

	mergeBaseCommitSHA, _, err := s.git.GetMergeBase(ctx, repoPath, "origin",
		baseCommitSHA.String(), headCommitSHA.String())
	if err != nil {
		return MergeOutput{}, fmt.Errorf("failed to get merge base: %w", err)
	}

	if headCommitSHA.Equal(mergeBaseCommitSHA) {
		return MergeOutput{}, errors.InvalidArgument("head branch doesn't contain any new commits.")
	}

	// find short stat and number of commits

	shortStat, err := s.git.DiffShortStat(ctx, repoPath, baseCommitSHA.String(), headCommitSHA.String(), true)
	if err != nil {
		return MergeOutput{}, errors.Internal(err,
			"failed to find short stat between %s and %s", baseCommitSHA, headCommitSHA)
	}
	changedFileCount := shortStat.Files

	commitCount, err := merge.CommitCount(ctx, repoPath, baseCommitSHA.String(), headCommitSHA.String())
	if err != nil {
		return MergeOutput{}, fmt.Errorf("failed to find commit count for merge check: %w", err)
	}

	// handle simple merge check

	if params.RefType == enum.RefTypeUndefined {
		_, _, conflicts, err := merge.FindConflicts(ctx, repoPath, baseCommitSHA.String(), headCommitSHA.String())
		if err != nil {
			return MergeOutput{}, errors.Internal(err,
				"Merge check failed to find conflicts between commits %s and %s",
				baseCommitSHA.String(), headCommitSHA.String())
		}

		log.Debug().Msg("merged check completed")

		return MergeOutput{
			BaseSHA:          baseCommitSHA,
			HeadSHA:          headCommitSHA,
			MergeBaseSHA:     mergeBaseCommitSHA,
			MergeSHA:         sha.None,
			CommitCount:      commitCount,
			ChangedFileCount: changedFileCount,
			ConflictFiles:    conflicts,
		}, nil
	}

	// author and committer

	now := time.Now().UTC()

	committer := api.Signature{Identity: api.Identity(params.Actor), When: now}

	if params.Committer != nil {
		committer.Identity = api.Identity(*params.Committer)
	}
	if params.CommitterDate != nil {
		committer.When = *params.CommitterDate
	}

	author := committer

	if params.Author != nil {
		author.Identity = api.Identity(*params.Author)
	}
	if params.AuthorDate != nil {
		author.When = *params.AuthorDate
	}

	// merge message

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

	// merge

	refUpdater, err := hook.CreateRefUpdater(s.hookClientFactory, params.EnvVars, repoPath, refPath)
	if err != nil {
		return MergeOutput{}, errors.Internal(err, "failed to create ref updater object")
	}

	if err := refUpdater.InitOld(ctx, refOldValue); err != nil {
		return MergeOutput{}, errors.Internal(err, "failed to set old reference value for ref updater")
	}

	mergeCommitSHA, conflicts, err := mergeFunc(
		ctx,
		refUpdater,
		repoPath, s.tmpDir,
		&author, &committer,
		mergeMsg,
		mergeBaseCommitSHA, baseCommitSHA, headCommitSHA)
	if err != nil {
		return MergeOutput{}, errors.Internal(err, "failed to merge %q to %q in %q using the %q merge method",
			params.HeadBranch, params.BaseBranch, params.RepoUID, mergeMethod)
	}
	if len(conflicts) > 0 {
		return MergeOutput{
			BaseSHA:          baseCommitSHA,
			HeadSHA:          headCommitSHA,
			MergeBaseSHA:     mergeBaseCommitSHA,
			MergeSHA:         sha.None,
			CommitCount:      commitCount,
			ChangedFileCount: changedFileCount,
			ConflictFiles:    conflicts,
		}, nil
	}

	return MergeOutput{
		BaseSHA:          baseCommitSHA,
		HeadSHA:          headCommitSHA,
		MergeBaseSHA:     mergeBaseCommitSHA,
		MergeSHA:         mergeCommitSHA,
		CommitCount:      commitCount,
		ChangedFileCount: changedFileCount,
		ConflictFiles:    nil,
	}, nil
}

type MergeBaseParams struct {
	ReadParams
	Ref1 string
	Ref2 string
}

func (p *MergeBaseParams) Validate() error {
	if err := p.ReadParams.Validate(); err != nil {
		return err
	}

	if p.Ref1 == "" {
		// needs better naming
		return errors.InvalidArgument("first reference cannot be empty")
	}

	if p.Ref2 == "" {
		// needs better naming
		return errors.InvalidArgument("second reference cannot be empty")
	}

	return nil
}

type MergeBaseOutput struct {
	MergeBaseSHA sha.SHA
}

func (s *Service) MergeBase(
	ctx context.Context,
	params MergeBaseParams,
) (MergeBaseOutput, error) {
	if err := params.Validate(); err != nil {
		return MergeBaseOutput{}, err
	}

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

	result, _, err := s.git.GetMergeBase(ctx, repoPath, "", params.Ref1, params.Ref2)
	if err != nil {
		return MergeBaseOutput{}, err
	}

	return MergeBaseOutput{
		MergeBaseSHA: result,
	}, nil
}

type IsAncestorParams struct {
	ReadParams
	AncestorCommitSHA   sha.SHA
	DescendantCommitSHA sha.SHA
}

type IsAncestorOutput struct {
	Ancestor bool
}

func (s *Service) IsAncestor(
	ctx context.Context,
	params IsAncestorParams,
) (IsAncestorOutput, error) {
	repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID)

	result, err := s.git.IsAncestor(
		ctx,
		repoPath,
		params.AlternateObjectDirs,
		params.AncestorCommitSHA,
		params.DescendantCommitSHA,
	)
	if err != nil {
		return IsAncestorOutput{}, err
	}

	return IsAncestorOutput{
		Ancestor: result,
	}, nil
}
