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

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

	"github.com/harness/gitness/app/api/controller"
	"github.com/harness/gitness/app/api/usererror"
	"github.com/harness/gitness/app/auth"
	pullreqevents "github.com/harness/gitness/app/events/pullreq"
	"github.com/harness/gitness/app/paths"
	"github.com/harness/gitness/app/services/codeowners"
	"github.com/harness/gitness/app/services/instrument"
	"github.com/harness/gitness/app/services/protection"
	"github.com/harness/gitness/audit"
	"github.com/harness/gitness/contextutil"
	"github.com/harness/gitness/errors"
	"github.com/harness/gitness/git"
	gitenum "github.com/harness/gitness/git/enum"
	"github.com/harness/gitness/git/sha"
	"github.com/harness/gitness/types"
	"github.com/harness/gitness/types/enum"

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

type MergeInput struct {
	Method    enum.MergeMethod `json:"method"`
	SourceSHA string           `json:"source_sha"`
	Title     string           `json:"title"`
	Message   string           `json:"message"`

	BypassRules bool `json:"bypass_rules"`
	DryRun      bool `json:"dry_run"`
	DryRunRules bool `json:"dry_run_rules"`
}

func (in *MergeInput) sanitize() error {
	if in.Method == "" && !in.DryRun && !in.DryRunRules {
		return usererror.BadRequest("merge method must be provided if dry run is false")
	}

	if in.SourceSHA == "" {
		return usererror.BadRequest("source SHA must be provided")
	}

	if in.Method != "" {
		method, ok := in.Method.Sanitize()
		if !ok {
			return usererror.BadRequestf("unsupported merge method: %s", in.Method)
		}

		in.Method = method
	}

	// cleanup title / message (NOTE: git doesn't support white space only)
	in.Title = strings.TrimSpace(in.Title)
	in.Message = strings.TrimSpace(in.Message)

	if (in.Method == enum.MergeMethodRebase || in.Method == enum.MergeMethodFastForward) &&
		(in.Title != "" || in.Message != "") {
		return usererror.BadRequestf(
			"merge method %q doesn't support customizing commit title and message", in.Method)
	}

	return nil
}

// Merge merges a pull request.
//
// It supports dry running by providing the DryRun=true. Dry running can be used to find any rule violations that
// might block the merging. Dry running typically should be used with BypassRules=true.
//
// MergeMethod doesn't need to be provided for dry running. If no MergeMethod has been provided the function will
// return allowed merge methods. Rules can limit allowed merge methods.
//
// If the pull request has been successfully merged the function will return the SHA of the merge commit.
//
//nolint:gocognit,gocyclo,cyclop
func (c *Controller) Merge(
	ctx context.Context,
	session *auth.Session,
	repoRef string,
	pullreqNum int64,
	in *MergeInput,
) (*types.MergeResponse, *types.MergeViolations, error) {
	if err := in.sanitize(); err != nil {
		return nil, nil, err
	}

	requiredPermission := enum.PermissionRepoPush
	if in.DryRunRules || in.DryRun {
		requiredPermission = enum.PermissionRepoView
	}

	targetRepo, err := c.getRepoCheckAccess(ctx, session, repoRef, requiredPermission)
	if err != nil {
		return nil, nil, fmt.Errorf("failed to acquire access to target repo: %w", err)
	}

	// the max time we give a merge to succeed
	const timeout = 3 * time.Minute

	// lock the repo only if actual git merge would be attempted
	if !in.DryRunRules {
		var lockID int64 // 0 means locking repo level for prs (for actual merging)
		if in.DryRun {
			lockID = pullreqNum // dryrun doesn't need repo level lock
		}

		// if two requests for merging comes at the same time then unlock will lock
		// first one and second one will wait, when first one is done then second one
		// continue with latest data from db with state merged and return error that
		// pr is already merged.
		unlock, err := c.locker.LockPR(
			ctx,
			targetRepo.ID,
			lockID,
			timeout+30*time.Second, // add 30s to the lock to give enough time for pre + post merge
		)
		if err != nil {
			return nil, nil, fmt.Errorf("failed to lock repository for pull request merge: %w", err)
		}
		defer unlock()
	}

	pr, err := c.pullreqStore.FindByNumber(ctx, targetRepo.ID, pullreqNum)
	if err != nil {
		return nil, nil, fmt.Errorf("failed to get pull request by number: %w", err)
	}

	if pr.Merged != nil {
		return nil, nil, usererror.BadRequest("Pull request already merged")
	}

	if pr.State != enum.PullReqStateOpen {
		return nil, nil, usererror.BadRequest("Pull request must be open")
	}

	if pr.SourceSHA != in.SourceSHA {
		return nil, nil,
			usererror.BadRequest("A newer commit is available. Only the latest commit can be merged.")
	}

	if pr.IsDraft && !in.DryRunRules && !in.DryRun {
		return nil, nil, usererror.BadRequest(
			"Draft pull requests can't be merged. Clear the draft flag first.",
		)
	}

	reviewers, err := c.reviewerStore.List(ctx, pr.ID)
	if err != nil {
		return nil, nil, fmt.Errorf("failed to load list of reviwers: %w", err)
	}

	targetWriteParams, err := controller.CreateRPCInternalWriteParams(ctx, c.urlProvider, session, targetRepo)
	if err != nil {
		return nil, nil, fmt.Errorf("failed to create RPC write params: %w", err)
	}

	sourceRepo := targetRepo
	sourceWriteParams := targetWriteParams
	if pr.SourceRepoID != pr.TargetRepoID {
		sourceWriteParams, err = controller.CreateRPCInternalWriteParams(ctx, c.urlProvider, session, sourceRepo)
		if err != nil {
			return nil, nil, fmt.Errorf("failed to create RPC write params: %w", err)
		}

		sourceRepo, err = c.repoStore.Find(ctx, pr.SourceRepoID)
		if err != nil {
			return nil, nil, fmt.Errorf("failed to get source repository: %w", err)
		}
	}

	protectionRules, isRepoOwner, err := c.fetchRules(ctx, session, targetRepo)
	if err != nil {
		return nil, nil, fmt.Errorf("failed to fetch rules: %w", err)
	}

	checkResults, err := c.checkStore.ListResults(ctx, targetRepo.ID, pr.SourceSHA)
	if err != nil {
		return nil, nil, fmt.Errorf("failed to list status checks: %w", err)
	}

	codeOwnerWithApproval, err := c.codeOwners.Evaluate(ctx, sourceRepo, pr, reviewers)
	// check for error and ignore if it is codeowners file not found else throw error
	if err != nil && !errors.Is(err, codeowners.ErrNotFound) {
		return nil, nil, fmt.Errorf("CODEOWNERS evaluation failed: %w", err)
	}

	ruleOut, violations, err := protectionRules.MergeVerify(ctx, protection.MergeVerifyInput{
		ResolveUserGroupID: c.userGroupService.ListUserIDsByGroupIDs,
		Actor:              &session.Principal,
		AllowBypass:        in.BypassRules,
		IsRepoOwner:        isRepoOwner,
		TargetRepo:         targetRepo,
		SourceRepo:         sourceRepo,
		PullReq:            pr,
		Reviewers:          reviewers,
		Method:             in.Method, // the method can be empty for dry run or dry run rules
		CheckResults:       checkResults,
		CodeOwners:         codeOwnerWithApproval,
	})
	if err != nil {
		return nil, nil, fmt.Errorf("failed to verify protection rules: %w", err)
	}

	if in.DryRunRules {
		return &types.MergeResponse{
			BranchDeleted:  ruleOut.DeleteSourceBranch,
			RuleViolations: violations,

			DryRunRules:                         true,
			AllowedMethods:                      ruleOut.AllowedMethods,
			RequiresCodeOwnersApproval:          ruleOut.RequiresCodeOwnersApproval,
			RequiresCodeOwnersApprovalLatest:    ruleOut.RequiresCodeOwnersApprovalLatest,
			RequiresCommentResolution:           ruleOut.RequiresCommentResolution,
			RequiresNoChangeRequests:            ruleOut.RequiresNoChangeRequests,
			MinimumRequiredApprovalsCount:       ruleOut.MinimumRequiredApprovalsCount,
			MinimumRequiredApprovalsCountLatest: ruleOut.MinimumRequiredApprovalsCountLatest,
		}, nil, nil
	}

	// we want to complete the merge independent of request cancel - start with new, time restricted context.
	// TODO: This is a small change to reduce likelihood of dirty state.
	// We still require a proper solution to handle an application crash or very slow execution times
	// (which could cause an unlocking pre operation completion).
	ctx, cancel := context.WithTimeout(
		contextutil.WithNewValues(context.Background(), ctx),
		timeout,
	)
	defer cancel()

	//nolint:nestif
	if in.DryRun {
		// As the merge API is always executed under a global lock, we use the opportunity of dry-running the merge
		// to check the PR's mergeability status if it's currently "unchecked". This can happen if the target branch
		// has advanced. It's possible that the merge base commit is different too.
		// So, the next time the API gets called for the same PR the mergeability status will not be unchecked.
		// Without dry-run the execution would proceed below and would either merge the PR or set the conflict status.

		var mergeOutput git.MergeOutput

		// We distinguish two types when checking mergeability: Rebase and Non-Rebase.
		// * Merge methods Merge and Squash will always have the same results.
		// * Merge method Rebase is special because it must always check all commits, one at a time.
		// * Merge method Fast-Forward can never have conflicts,
		//   but for it the merge base SHA must be equal to target branch SHA.
		// The result of the tests will be stored (think cached) in the database for these two types
		// in the fields merge_check_status and rebase_check_status.

		if in.Method == "" {
			in.Method = enum.MergeMethodMerge
		}

		checkMergeability := func(method enum.MergeMethod) bool {
			switch method {
			case enum.MergeMethodMerge, enum.MergeMethodSquash:
				return pr.MergeCheckStatus == enum.MergeCheckStatusUnchecked
			case enum.MergeMethodRebase:
				return pr.RebaseCheckStatus == enum.MergeCheckStatusUnchecked
			case enum.MergeMethodFastForward:
				// Always check for ff merge. There can never be conflicts,
				// but we are interested in if it returns the conflict error and merge-output data.
				return true
			default:
				return true // should not happen
			}
		}(in.Method)

		if checkMergeability {
			mergeOutput, err = c.git.Merge(ctx, &git.MergeParams{
				WriteParams:     targetWriteParams,
				BaseBranch:      pr.TargetBranch,
				HeadRepoUID:     sourceRepo.GitUID,
				HeadBranch:      pr.SourceBranch,
				RefType:         gitenum.RefTypeUndefined, // update no refs -> no commit will be created
				HeadExpectedSHA: sha.Must(in.SourceSHA),
				Method:          gitenum.MergeMethod(in.Method),
			})
			if err != nil {
				return nil, nil, fmt.Errorf("failed merge check with method=%s: %w", in.Method, err)
			}

			pr, err = c.pullreqStore.UpdateOptLock(ctx, pr, func(pr *types.PullReq) error {
				if pr.SourceSHA != mergeOutput.HeadSHA.String() {
					return errors.New("source SHA has changed")
				}
				// actual merge is using a different lock - ensure we don't overwrite any merge results.
				if pr.State != enum.PullReqStateOpen {
					return usererror.BadRequest("Pull request must be open")
				}

				pr.MergeBaseSHA = mergeOutput.MergeBaseSHA.String()
				pr.MergeTargetSHA = ptr.String(mergeOutput.BaseSHA.String())
				pr.MergeSHA = nil // dry-run doesn't create a merge commit so output is empty.

				pr.UpdateMergeOutcome(in.Method, mergeOutput.ConflictFiles)

				pr.Stats.DiffStats = types.NewDiffStats(
					mergeOutput.CommitCount,
					mergeOutput.ChangedFileCount,
					mergeOutput.Additions,
					mergeOutput.Deletions,
				)

				return nil
			})
			if err != nil {
				// non-critical error
				log.Ctx(ctx).Warn().Err(err).Msg("failed to update unchecked pull request")
			} else {
				c.sseStreamer.Publish(ctx, targetRepo.ParentID, enum.SSETypePullReqUpdated, pr)
			}
		}

		var conflicts []string
		if in.Method == enum.MergeMethodRebase {
			conflicts = pr.RebaseConflicts
		} else {
			conflicts = pr.MergeConflicts
		}

		// With in.DryRun=true this function never returns types.MergeViolations
		out := &types.MergeResponse{
			BranchDeleted:  ruleOut.DeleteSourceBranch,
			RuleViolations: violations,

			// values only returned by dry run
			DryRun:                              true,
			Mergeable:                           len(conflicts) == 0,
			ConflictFiles:                       conflicts,
			AllowedMethods:                      ruleOut.AllowedMethods,
			RequiresCodeOwnersApproval:          ruleOut.RequiresCodeOwnersApproval,
			RequiresCodeOwnersApprovalLatest:    ruleOut.RequiresCodeOwnersApprovalLatest,
			RequiresCommentResolution:           ruleOut.RequiresCommentResolution,
			RequiresNoChangeRequests:            ruleOut.RequiresNoChangeRequests,
			MinimumRequiredApprovalsCount:       ruleOut.MinimumRequiredApprovalsCount,
			MinimumRequiredApprovalsCountLatest: ruleOut.MinimumRequiredApprovalsCountLatest,
		}

		return out, nil, nil
	}

	if protection.IsCritical(violations) {
		sb := strings.Builder{}
		for i, ruleViolation := range violations {
			if i > 0 {
				sb.WriteByte(',')
			}
			sb.WriteString(ruleViolation.Rule.Identifier)
			sb.WriteString(":[")
			for j, v := range ruleViolation.Violations {
				if j > 0 {
					sb.WriteByte(',')
				}
				sb.WriteString(v.Code)
			}
			sb.WriteString("]")
		}

		log.Ctx(ctx).Info().Msgf("aborting pull request merge because of rule violations: %s", sb.String())

		return nil, &types.MergeViolations{
			RuleViolations: violations,
			Message:        protection.GenerateErrorMessageForBlockingViolations(violations),
		}, nil
	}

	// commit details: author, committer and message

	var author *git.Identity

	switch in.Method {
	case enum.MergeMethodMerge:
		author = controller.IdentityFromPrincipalInfo(*session.Principal.ToPrincipalInfo())
	case enum.MergeMethodSquash:
		author = controller.IdentityFromPrincipalInfo(pr.Author)
	case enum.MergeMethodRebase, enum.MergeMethodFastForward:
		author = nil // Not important for these merge methods: the author info in the commits will be preserved.
	}

	var committer *git.Identity

	switch in.Method {
	case enum.MergeMethodMerge, enum.MergeMethodSquash:
		committer = controller.SystemServicePrincipalInfo()
	case enum.MergeMethodRebase:
		committer = controller.IdentityFromPrincipalInfo(*session.Principal.ToPrincipalInfo())
	case enum.MergeMethodFastForward:
		committer = nil // Not important for fast-forward merge
	}

	// backfill commit title if none provided
	if in.Title == "" {
		switch in.Method {
		case enum.MergeMethodMerge:
			in.Title = fmt.Sprintf("Merge branch '%s' of %s (#%d)", pr.SourceBranch, sourceRepo.Path, pr.Number)
		case enum.MergeMethodSquash:
			in.Title = fmt.Sprintf("%s (#%d)", pr.Title, pr.Number)
		case enum.MergeMethodRebase, enum.MergeMethodFastForward:
			// Not used.
		}
	}

	// create merge commit(s)

	log.Ctx(ctx).Debug().Msgf("all pre-check passed, merge PR")

	now := time.Now()
	mergeOutput, err := c.git.Merge(ctx, &git.MergeParams{
		WriteParams:     targetWriteParams,
		BaseBranch:      pr.TargetBranch,
		HeadRepoUID:     sourceRepo.GitUID,
		HeadBranch:      pr.SourceBranch,
		Message:         git.CommitMessage(in.Title, in.Message),
		Committer:       committer,
		CommitterDate:   &now,
		Author:          author,
		AuthorDate:      &now,
		RefType:         gitenum.RefTypeBranch,
		RefName:         pr.TargetBranch,
		HeadExpectedSHA: sha.Must(in.SourceSHA),
		Method:          gitenum.MergeMethod(in.Method),
	})
	if err != nil {
		return nil, nil, fmt.Errorf("merge execution failed: %w", err)
	}
	//nolint:nestif
	if mergeOutput.MergeSHA.String() == "" || len(mergeOutput.ConflictFiles) > 0 {
		_, err = c.pullreqStore.UpdateOptLock(ctx, pr, func(pr *types.PullReq) error {
			if pr.SourceSHA != mergeOutput.HeadSHA.String() {
				return errors.New("source SHA has changed")
			}

			// update all Merge specific information
			pr.MergeBaseSHA = mergeOutput.MergeBaseSHA.String()
			pr.MergeTargetSHA = ptr.String(mergeOutput.BaseSHA.String())
			pr.MergeSHA = nil
			pr.UpdateMergeOutcome(in.Method, mergeOutput.ConflictFiles)
			pr.Stats.DiffStats = types.NewDiffStats(
				mergeOutput.CommitCount,
				mergeOutput.ChangedFileCount,
				mergeOutput.Additions,
				mergeOutput.Deletions,
			)
			return nil
		})
		if err != nil {
			// non-critical error
			log.Ctx(ctx).Warn().Err(err).Msg("failed to update pull request with conflict files")
		} else {
			c.sseStreamer.Publish(ctx, targetRepo.ParentID, enum.SSETypePullReqUpdated, pr)
		}

		log.Ctx(ctx).Info().Msg("aborting pull request merge because of conflicts")

		return nil, &types.MergeViolations{
			ConflictFiles:  mergeOutput.ConflictFiles,
			RuleViolations: violations,
			// In case of conflicting files we prioritize those for the error message.
			Message: fmt.Sprintf("Merge blocked by conflicting files: %v", mergeOutput.ConflictFiles),
		}, nil
	}

	log.Ctx(ctx).Debug().Msgf("successfully merged PR")

	mergedBy := session.Principal.ID

	var activitySeqMerge, activitySeqBranchDeleted int64
	pr, err = c.pullreqStore.UpdateOptLock(ctx, pr, func(pr *types.PullReq) error {
		pr.State = enum.PullReqStateMerged

		nowMilli := now.UnixMilli()

		pr.Merged = &nowMilli
		pr.MergedBy = &mergedBy
		pr.MergeMethod = &in.Method

		// update all Merge specific information (might be empty if previous merge check failed)
		// since this is the final operation on the PR, we update any sha that might've changed by now.
		pr.SourceSHA = mergeOutput.HeadSHA.String()
		pr.MergeTargetSHA = ptr.String(mergeOutput.BaseSHA.String())
		pr.MergeBaseSHA = mergeOutput.MergeBaseSHA.String()
		pr.MergeSHA = ptr.String(mergeOutput.MergeSHA.String())
		pr.MarkAsMerged()
		pr.Stats.DiffStats = types.NewDiffStats(
			mergeOutput.CommitCount,
			mergeOutput.ChangedFileCount,
			mergeOutput.Additions,
			mergeOutput.Deletions,
		)

		// update sequence for PR activities
		pr.ActivitySeq++
		activitySeqMerge = pr.ActivitySeq

		if ruleOut.DeleteSourceBranch {
			pr.ActivitySeq++
			activitySeqBranchDeleted = pr.ActivitySeq
		}

		return nil
	})
	if err != nil {
		return nil, nil, fmt.Errorf("failed to update pull request: %w", err)
	}

	pr.ActivitySeq = activitySeqMerge
	activityPayload := &types.PullRequestActivityPayloadMerge{
		MergeMethod:   in.Method,
		MergeSHA:      mergeOutput.MergeSHA.String(),
		TargetSHA:     mergeOutput.BaseSHA.String(),
		SourceSHA:     mergeOutput.HeadSHA.String(),
		RulesBypassed: protection.IsBypassed(violations),
	}
	if _, errAct := c.activityStore.CreateWithPayload(ctx, pr, mergedBy, activityPayload, nil); errAct != nil {
		// non-critical error
		log.Ctx(ctx).Err(errAct).Msgf("failed to write pull req merge activity")
	}

	c.eventReporter.Merged(ctx, &pullreqevents.MergedPayload{
		Base:        eventBase(pr, &session.Principal),
		MergeMethod: in.Method,
		MergeSHA:    mergeOutput.MergeSHA.String(),
		TargetSHA:   mergeOutput.BaseSHA.String(),
		SourceSHA:   mergeOutput.HeadSHA.String(),
	})

	var branchDeleted bool
	if ruleOut.DeleteSourceBranch {
		errDelete := c.git.DeleteBranch(ctx, &git.DeleteBranchParams{
			WriteParams: sourceWriteParams,
			BranchName:  pr.SourceBranch,
		})
		if errDelete != nil {
			// non-critical error
			log.Ctx(ctx).Err(errDelete).Msgf("failed to delete source branch after merging")
		} else {
			branchDeleted = true

			// NOTE: there is a chance someone pushed on the branch between merge and delete.
			// Either way, we'll use the SHA that was merged with for the activity to be consistent from PR perspective.
			pr.ActivitySeq = activitySeqBranchDeleted
			if _, errAct := c.activityStore.CreateWithPayload(ctx, pr, mergedBy,
				&types.PullRequestActivityPayloadBranchDelete{SHA: in.SourceSHA}, nil); errAct != nil {
				// non-critical error
				log.Ctx(ctx).Err(errAct).
					Msgf("failed to write pull request activity for successful automatic branch delete")
			}
		}
	}

	c.sseStreamer.Publish(ctx, targetRepo.ParentID, enum.SSETypePullReqUpdated, pr)

	if protection.IsBypassed(violations) {
		err = c.auditService.Log(ctx,
			session.Principal,
			audit.NewResource(
				audit.ResourceTypeRepository,
				sourceRepo.Identifier,
				audit.RepoPath,
				sourceRepo.Path,
				audit.BypassedResourceType,
				audit.BypassedResourceTypePullRequest,
				audit.BypassedResourceName,
				strconv.FormatInt(pr.Number, 10),
				audit.ResourceName,
				fmt.Sprintf(
					audit.BypassPullReqLabelFormat,
					sourceRepo.Identifier,
					strconv.FormatInt(pr.Number, 10),
				),
				audit.BypassAction,
				audit.BypassActionMerged,
			),
			audit.ActionBypassed,
			paths.Parent(sourceRepo.Path),
			audit.WithNewObject(audit.PullRequestObject{
				PullReq:        *pr,
				RepoPath:       sourceRepo.Path,
				RuleViolations: violations,
			}),
		)
		if err != nil {
			log.Ctx(ctx).Warn().Msgf("failed to insert audit log for merge pull request operation: %s", err)
		}
	}

	err = c.instrumentation.Track(ctx, instrument.Event{
		Type:      instrument.EventTypeMergePullRequest,
		Principal: session.Principal.ToPrincipalInfo(),
		Path:      sourceRepo.Path,
		Properties: map[instrument.Property]any{
			instrument.PropertyRepositoryID:   sourceRepo.ID,
			instrument.PropertyRepositoryName: sourceRepo.Identifier,
			instrument.PropertyPullRequestID:  pr.Number,
			instrument.PropertyMergeStrategy:  in.Method,
		},
	})
	if err != nil {
		log.Ctx(ctx).Warn().Msgf("failed to insert instrumentation record for merge pr operation: %s", err)
	}
	return &types.MergeResponse{
		SHA:            mergeOutput.MergeSHA.String(),
		BranchDeleted:  branchDeleted,
		RuleViolations: violations,
	}, nil, nil
}
