// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.

package codecomments

import (
	"context"

	"github.com/harness/gitness/gitrpc"
	gitrpcenum "github.com/harness/gitness/gitrpc/enum"
	"github.com/harness/gitness/types"

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

// Migrator is a utility used to migrate code comments after update of the pull request's source branch.
type Migrator struct {
	gitRPCClient gitrpc.Interface
}

// MigrateNew updates the "+" (the added lines) part of code comments
// after a new commit on the pull request's source branch.
// The parameter newSHA should contain the latest commit SHA of the pull request's source branch.
func (migrator *Migrator) MigrateNew(
	ctx context.Context,
	repoGitUID string,
	newSHA string,
	comments []*types.CodeComment,
) {
	migrator.migrate(
		ctx,
		repoGitUID,
		newSHA,
		comments,
		func(codeComment *types.CodeComment) string {
			return codeComment.SourceSHA
		},
		func(codeComment *types.CodeComment, sha string) {
			codeComment.SourceSHA = sha
		},
		func(codeComment *types.CodeComment) (int, int) {
			return codeComment.LineNew, codeComment.LineNew + codeComment.SpanNew - 1
		},
		func(codeComment *types.CodeComment, line int) {
			codeComment.LineNew += line
		},
	)
}

// MigrateOld updates the "-" (the removes lines) part of code comments
// after the pull request's change of the merge base commit.
func (migrator *Migrator) MigrateOld(
	ctx context.Context,
	repoGitUID string,
	newSHA string,
	comments []*types.CodeComment,
) {
	migrator.migrate(
		ctx,
		repoGitUID,
		newSHA,
		comments,
		func(codeComment *types.CodeComment) string {
			return codeComment.MergeBaseSHA
		},
		func(codeComment *types.CodeComment, sha string) {
			codeComment.MergeBaseSHA = sha
		},
		func(codeComment *types.CodeComment) (int, int) {
			return codeComment.LineOld, codeComment.LineOld + codeComment.SpanOld - 1
		},
		func(codeComment *types.CodeComment, line int) {
			codeComment.LineOld += line
		},
	)
}

//nolint:gocognit,funlen // refactor if needed
func (migrator *Migrator) migrate(
	ctx context.Context,
	repoGitUID string,
	newSHA string,
	comments []*types.CodeComment,
	getSHA func(codeComment *types.CodeComment) string,
	setSHA func(codeComment *types.CodeComment, sha string),
	getCommentStartEnd func(codeComment *types.CodeComment) (int, int),
	updateCommentLine func(codeComment *types.CodeComment, line int),
) {
	if len(comments) == 0 {
		return
	}

	commitMap := mapCodeComments(comments, getSHA)

	for commentSHA, fileMap := range commitMap {
		// get all hunk headers for the diff between the SHA that's stored in the comment and the new SHA.
		diffSummary, errDiff := migrator.gitRPCClient.GetDiffHunkHeaders(ctx, gitrpc.GetDiffHunkHeadersParams{
			ReadParams: gitrpc.ReadParams{
				RepoUID: repoGitUID,
			},
			SourceCommitSHA: commentSHA,
			TargetCommitSHA: newSHA,
		})
		if gitrpc.ErrorStatus(errDiff) == gitrpc.StatusNotFound {
			// Handle the commit SHA not found error and mark all code comments as outdated.
			for _, codeComments := range fileMap {
				for _, codeComment := range codeComments {
					codeComment.Outdated = true
				}
			}
			continue
		}
		if errDiff != nil {
			log.Ctx(ctx).Err(errDiff).
				Msgf("failed to get git diff between comment's sha %s and the latest %s", commentSHA, newSHA)
			continue
		}

		// Traverse all the changed files
		for _, file := range diffSummary.Files {
			var codeComments []*types.CodeComment

			codeComments = fileMap[file.FileHeader.OldName]

			// Handle file renames
			if file.FileHeader.OldName != file.FileHeader.NewName {
				if len(codeComments) == 0 {
					// If the code comments are not found using the old name of the file, try with the new name.
					codeComments = fileMap[file.FileHeader.NewName]
				} else {
					// Update the code comment's path to the new file name
					for _, cc := range codeComments {
						cc.Path = file.FileHeader.NewName
					}
				}
			}

			// Handle file delete
			if _, isDeleted := file.FileHeader.Extensions[gitrpcenum.DiffExtHeaderDeletedFileMode]; isDeleted {
				for _, codeComment := range codeComments {
					codeComment.Outdated = true
				}
				continue
			}

			// Handle new files - shouldn't happen because code comments should exist for a non-existing file.
			if _, isAdded := file.FileHeader.Extensions[gitrpcenum.DiffExtHeaderNewFileMode]; isAdded {
				for _, codeComment := range codeComments {
					codeComment.Outdated = true
				}
				continue
			}

			for _, hunk := range file.HunkHeaders {
				for _, cc := range codeComments {
					ccStart, ccEnd := getCommentStartEnd(cc)
					outdated, moveDelta := processCodeComment(ccStart, ccEnd, hunk)
					cc.Outdated = outdated
					updateCommentLine(cc, moveDelta)
				}
			}
		}

		for _, codeComments := range fileMap {
			for _, codeComment := range codeComments {
				if codeComment.Outdated {
					continue
				}
				setSHA(codeComment, newSHA)
			}
		}
	}
}

// mapCodeComments groups code comments to maps, first by commit SHA and then by file name.
// It assumes the incoming list is already sorted.
func mapCodeComments(
	comments []*types.CodeComment,
	extractSHA func(*types.CodeComment) string,
) map[string]map[string][]*types.CodeComment {
	commitMap := map[string]map[string][]*types.CodeComment{}
	for _, comment := range comments {
		commitSHA := extractSHA(comment)

		fileMap := commitMap[commitSHA]
		if fileMap == nil {
			fileMap = map[string][]*types.CodeComment{}
		}

		fileComments := fileMap[comment.Path]
		fileComments = append(fileComments, comment)
		fileMap[comment.Path] = fileComments

		commitMap[commitSHA] = fileMap
	}

	return commitMap
}

func processCodeComment(ccStart, ccEnd int, h gitrpc.HunkHeader) (outdated bool, moveDelta int) {
	// it's outdated if code is changed (old code) inside the code comment or
	// if there are added lines inside the code comment, don't care about how many (NewSpan).
	outdated = (h.OldSpan > 0 && ccEnd >= h.OldLine && ccStart <= h.OldLine+h.OldSpan-1) ||
		(h.NewSpan > 0 && h.NewLine > ccStart && h.NewLine <= ccEnd)

	if outdated {
		return // outdated comments aren't moved
	}

	if ccEnd <= h.OldLine {
		return // the change described by the hunk header is below the code comment, so it doesn't affect it
	}

	moveDelta = h.NewSpan - h.OldSpan

	return
}
