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

import (
	"context"
	"encoding/json"
	"fmt"
	"sort"
	"strings"
	"time"

	"github.com/harness/gitness/app/store"
	"github.com/harness/gitness/app/url"
	"github.com/harness/gitness/errors"
	"github.com/harness/gitness/git"
	"github.com/harness/gitness/git/parser"
	"github.com/harness/gitness/lock"
	gitness_store "github.com/harness/gitness/store"
	"github.com/harness/gitness/store/database/dbtx"
	"github.com/harness/gitness/types"
	"github.com/harness/gitness/types/enum"

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

// PullReq is pull request migrate.
type PullReq struct {
	urlProvider                 url.Provider
	git                         git.Interface
	principalStore              store.PrincipalStore
	spaceStore                  store.SpaceStore
	repoStore                   store.RepoStore
	pullReqStore                store.PullReqStore
	pullReqActStore             store.PullReqActivityStore
	labelStore                  store.LabelStore
	labelValueStore             store.LabelValueStore
	pullReqLabelAssignmentStore store.PullReqLabelAssignmentStore
	tx                          dbtx.Transactor
	mtxManager                  lock.MutexManager
}

func NewPullReq(
	urlProvider url.Provider,
	git git.Interface,
	principalStore store.PrincipalStore,
	spaceStore store.SpaceStore,
	repoStore store.RepoStore,
	pullReqStore store.PullReqStore,
	pullReqActStore store.PullReqActivityStore,
	labelStore store.LabelStore,
	labelValueStore store.LabelValueStore,
	pullReqLabelAssignmentStore store.PullReqLabelAssignmentStore,
	tx dbtx.Transactor,
	mtxManager lock.MutexManager,
) *PullReq {
	return &PullReq{
		urlProvider:                 urlProvider,
		git:                         git,
		principalStore:              principalStore,
		spaceStore:                  spaceStore,
		repoStore:                   repoStore,
		pullReqStore:                pullReqStore,
		pullReqActStore:             pullReqActStore,
		labelStore:                  labelStore,
		labelValueStore:             labelValueStore,
		pullReqLabelAssignmentStore: pullReqLabelAssignmentStore,
		tx:                          tx,
		mtxManager:                  mtxManager,
	}
}

type repoImportState struct {
	git                         git.Interface
	readParams                  git.ReadParams
	principalStore              store.PrincipalStore
	spaceStore                  store.SpaceStore
	pullReqActivityStore        store.PullReqActivityStore
	labelStore                  store.LabelStore
	labelValueStore             store.LabelValueStore
	pullReqLabelAssignmentStore store.PullReqLabelAssignmentStore
	branchCheck                 map[string]*git.Branch
	principals                  map[string]*types.Principal
	unknownEmails               map[int]map[string]bool
	labels                      map[string]int64            // map for labels {"label.key":label.id,}
	labelValues                 map[int64]map[string]*int64 // map for label values {label.id:{"value-key":value-id,}}
	migrator                    types.Principal
	scope                       int64 // depth of space used for labels
}

// Import load provided pull requests in go-scm format and imports them.
//
//nolint:gocognit
func (migrate PullReq) Import(
	ctx context.Context,
	migrator types.Principal,
	repo *types.Repository,
	extPullReqs []*ExternalPullRequest,
) ([]*types.PullReq, error) {
	readParams := git.ReadParams{RepoUID: repo.GitUID}

	repoState := repoImportState{
		git:                         migrate.git,
		readParams:                  readParams,
		principalStore:              migrate.principalStore,
		spaceStore:                  migrate.spaceStore,
		pullReqActivityStore:        migrate.pullReqActStore,
		labelStore:                  migrate.labelStore,
		labelValueStore:             migrate.labelValueStore,
		pullReqLabelAssignmentStore: migrate.pullReqLabelAssignmentStore,
		branchCheck:                 map[string]*git.Branch{},
		principals:                  map[string]*types.Principal{},
		unknownEmails:               map[int]map[string]bool{},
		labels:                      map[string]int64{},
		labelValues:                 map[int64]map[string]*int64{},
		migrator:                    migrator,
		scope:                       0,
	}

	pullReqUnique := map[int]ExternalPullRequest{}
	pullReqComments := map[*types.PullReq][]ExternalComment{}

	pullReqs := make([]*types.PullReq, 0, len(extPullReqs))
	// create the PR objects, one by one. Each pull request will mutate the repository object (to update the counters).
	for _, extPullReqData := range extPullReqs {
		extPullReq := &extPullReqData.PullRequest

		if _, exists := pullReqUnique[extPullReq.Number]; exists {
			return nil, errors.Conflict("duplicate pull request number %d", extPullReq.Number)
		}
		pullReqUnique[extPullReq.Number] = *extPullReqData

		pr, err := repoState.convertPullReq(ctx, repo, extPullReqData)
		if err != nil {
			return nil, fmt.Errorf("failed to import pull request %d: %w", extPullReq.Number, err)
		}

		pullReqs = append(pullReqs, pr)
		pullReqComments[pr] = extPullReqData.Comments
	}

	if len(pullReqs) == 0 { // nothing to do: exit early to avoid accessing the database
		return nil, nil
	}

	err := migrate.tx.WithTx(ctx, func(ctx context.Context) error {
		var deltaOpen, deltaClosed, deltaMerged int
		var maxNumber int64

		// Store the pull request objects and the comments.
		for _, pullReq := range pullReqs {
			if err := migrate.pullReqStore.Create(ctx, pullReq); err != nil {
				return fmt.Errorf("failed to import the pull request %d: %w", pullReq.Number, err)
			}

			switch pullReq.State {
			case enum.PullReqStateOpen:
				deltaOpen++
			case enum.PullReqStateClosed:
				deltaClosed++
			case enum.PullReqStateMerged:
				deltaMerged++
			}

			if maxNumber < pullReq.Number {
				maxNumber = pullReq.Number
			}

			comments, err := repoState.createComments(ctx, repo, pullReq, pullReqComments[pullReq])
			if err != nil {
				return fmt.Errorf("failed to import pull request comments: %w", err)
			}

			// Add a comment if any principal (PR author or commenter) were replaced by the fallback migrator principal
			if prUnknownEmails, ok := repoState.unknownEmails[int(pullReq.Number)]; ok && len(prUnknownEmails) != 0 {
				infoComment, err := repoState.createInfoComment(ctx, repo, pullReq)
				if err != nil {
					log.Ctx(ctx).Warn().Err(err).Msg("failed to add an informational comment for replacing non-existing users")
				} else {
					comments = append(comments, infoComment)
				}
			}

			prLabels := pullReqUnique[int(pullReq.Number)].PullRequest.Labels
			err = repoState.assignLabels(ctx, repo.ParentID, pullReq, prLabels)
			if err != nil {
				return fmt.Errorf("failed to assign pull request %d labels: %w", pullReq.Number, err)
			}

			// no need to update the pull request object in the DB if there are no comments.
			if len(comments) == 0 && len(prLabels) == 0 {
				continue
			}

			if err := migrate.pullReqStore.Update(ctx, pullReq); err != nil {
				return fmt.Errorf("failed to update pull request after importing of the comments: %w", err)
			}
		}

		// Update the repository

		repoUpdate, err := migrate.repoStore.Find(ctx, repo.ID)
		if err != nil {
			return fmt.Errorf("failed to fetch repo in pull request import: %w", err)
		}

		if repoUpdate.PullReqSeq < maxNumber {
			repoUpdate.PullReqSeq = maxNumber
		}
		repoUpdate.NumPulls += len(pullReqs)
		repoUpdate.NumOpenPulls += deltaOpen
		repoUpdate.NumClosedPulls += deltaClosed
		repoUpdate.NumMergedPulls += deltaMerged

		if err := migrate.repoStore.Update(ctx, repoUpdate); err != nil {
			return fmt.Errorf("failed to update repo in pull request import: %w", err)
		}

		return nil
	})
	if err != nil {
		return nil, err
	}

	return pullReqs, nil
}

// convertPullReq analyses external pull request object and creates types.PullReq object out of it.
func (r *repoImportState) convertPullReq(
	ctx context.Context,
	repo *types.Repository,
	extPullReqData *ExternalPullRequest,
) (*types.PullReq, error) {
	extPullReq := extPullReqData.PullRequest

	log := log.Ctx(ctx).With().
		Str("repo.identifier", repo.Identifier).
		Int("pullreq.number", extPullReq.Number).
		Logger()

	author, err := r.getPrincipalByEmail(ctx, extPullReq.Author.Email, extPullReq.Number, false)
	if err != nil {
		return nil, fmt.Errorf("failed to get pull request author: %w", err)
	}

	now := time.Now().UnixMilli()
	createdAt := timestampMillis(extPullReq.Created, now)
	updatedAt := timestampMillis(extPullReq.Updated, now)

	const maxTitleLen = 256
	const maxDescriptionLen = 100000 // This limit is deliberately higher than the limit in our API.
	if len(extPullReq.Title) > maxTitleLen {
		extPullReq.Title = extPullReq.Title[:maxTitleLen]
	}
	if len(extPullReq.Body) > maxDescriptionLen {
		extPullReq.Body = extPullReq.Body[:maxDescriptionLen]
	}

	pr := &types.PullReq{
		ID:              0, // the ID will be populated in the data layer
		Version:         0,
		Number:          int64(extPullReq.Number),
		CreatedBy:       author.ID,
		Created:         createdAt,
		Updated:         updatedAt,
		Edited:          updatedAt,
		Closed:          nil,
		State:           enum.PullReqStateOpen,
		IsDraft:         extPullReq.Draft,
		CommentCount:    0,
		UnresolvedCount: 0,
		Title:           extPullReq.Title,
		Description:     extPullReq.Body,
		SourceRepoID:    repo.ID,
		SourceBranch:    extPullReq.Head.Name,
		SourceSHA:       extPullReq.Head.SHA,
		TargetRepoID:    repo.ID,
		TargetBranch:    extPullReq.Base.Name,
		ActivitySeq:     0,
		// Merge related fields are all left unset and will be set depending on the PR state
	}

	params := git.ReadParams{RepoUID: repo.GitUID}

	// Set the state of the PR
	switch {
	case extPullReq.Merged:
		pr.State = enum.PullReqStateMerged
	case extPullReq.Closed:
		pr.State = enum.PullReqStateClosed
	default:
		pr.State = enum.PullReqStateOpen
	}

	// Update the PR depending on its state
	switch pr.State {
	case enum.PullReqStateMerged:
		// For merged PR's assume the Head.Sha and Base.Sha point to commits at the time of merging.

		pr.Merged = &pr.Updated
		pr.MergedBy = &author.ID             // Don't have real info for this - use the author.
		mergeMethod := enum.MergeMethodMerge // Don't know
		pr.MergeMethod = &mergeMethod

		pr.SourceSHA = extPullReq.Head.SHA
		pr.MergeTargetSHA = &extPullReq.Base.SHA
		pr.MergeBaseSHA = extPullReq.Base.SHA
		pr.MergeSHA = nil // Don't have this.
		pr.MarkAsMerged()

	case enum.PullReqStateClosed:
		// For closed PR's it's not important to verify existence of branches and commits.
		// If these don't exist the PR will be impossible to open.
		pr.SourceSHA = extPullReq.Head.SHA
		pr.MergeTargetSHA = &extPullReq.Base.SHA
		pr.MergeBaseSHA = extPullReq.Base.SHA
		pr.MergeSHA = nil
		pr.MergeConflicts = nil
		pr.MergeTargetSHA = nil
		pr.MarkAsMergeUnchecked()

		pr.Closed = &pr.Updated

	case enum.PullReqStateOpen:
		// For open PR we need to verify existence of branches and find to merge base.

		sourceBranch, err := r.git.GetBranch(ctx, &git.GetBranchParams{
			ReadParams: params,
			BranchName: extPullReq.Head.Name,
		})
		if err != nil {
			return nil, fmt.Errorf("failed to fetch source branch of an open pull request: %w", err)
		}

		// TODO: Cache this in the repoImportState - it's very likely that it will be the same for other PRs
		targetBranch, err := r.git.GetBranch(ctx, &git.GetBranchParams{
			ReadParams: params,
			BranchName: extPullReq.Base.Name,
		})
		if err != nil {
			return nil, fmt.Errorf("failed to fetch target branch of an open pull request: %w", err)
		}

		mergeBase, err := r.git.MergeBase(ctx, git.MergeBaseParams{
			ReadParams: params,
			Ref1:       sourceBranch.Branch.SHA.String(),
			Ref2:       targetBranch.Branch.SHA.String(),
		})
		if err != nil {
			return nil, fmt.Errorf("failed to find merge base an open pull request: %w", err)
		}

		sourceSHA := sourceBranch.Branch.SHA.String()
		targetSHA := targetBranch.Branch.SHA.String()
		pr.SourceSHA = sourceSHA
		pr.MergeTargetSHA = &targetSHA
		pr.MergeBaseSHA = mergeBase.MergeBaseSHA.String()
		pr.MarkAsMergeUnchecked()
	}

	log.Debug().Str("pullreq.state", string(pr.State)).Msg("importing pull request")

	return pr, nil
}

// createComments analyses external pull request comment objects and stores types.PullReqActivity object to the DB.
// It will mutate the pull request object to update counter fields.
func (r *repoImportState) createComments(
	ctx context.Context,
	repo *types.Repository,
	pullReq *types.PullReq,
	extComments []ExternalComment,
) ([]*types.PullReqActivity, error) {
	log := log.Ctx(ctx).With().
		Str("repo.id", repo.Identifier).
		Int("pullreq.number", int(pullReq.Number)).
		Logger()

	extThreads := generateThreads(extComments)

	comments := make([]*types.PullReqActivity, 0, len(extComments))
	for idxTopLevel, extThread := range extThreads {
		order := idxTopLevel + 1

		// Create the top level comment with the correct value of Order, SubOrder and ReplySeq.
		commentTopLevel, err := r.createComment(ctx, repo, pullReq, nil,
			order, 0, len(extThread.Replies), &extThread.TopLevel)
		if err != nil {
			return nil, fmt.Errorf("failed to create top level comment: %w", err)
		}

		comments = append(comments, commentTopLevel)

		for idxReply, extReply := range extThread.Replies {
			subOrder := idxReply + 1

			// Create the reply comment with the correct value of Order, SubOrder and ReplySeq.
			//nolint:gosec
			commentReply, err := r.createComment(ctx, repo, pullReq, &commentTopLevel.ID,
				order, subOrder, 0, &extReply)
			if err != nil {
				return nil, fmt.Errorf("failed to create reply comment: %w", err)
			}

			comments = append(comments, commentReply)
		}
	}

	log.Debug().Int("count", len(comments)).Msg("imported pull request comments")

	return comments, nil
}

// createComment analyses an external pull request comment object and creates types.PullReqActivity object out of it.
// It will mutate the pull request object to update counter fields.
func (r *repoImportState) createComment(
	ctx context.Context,
	repo *types.Repository,
	pullReq *types.PullReq,
	parentID *int64,
	order, subOrder, replySeq int,
	extComment *ExternalComment,
) (*types.PullReqActivity, error) {
	commenter, err := r.getPrincipalByEmail(ctx, extComment.Author.Email, int(pullReq.Number), false)
	if err != nil {
		return nil, fmt.Errorf("failed to get comment ID=%d author: %w", extComment.ID, err)
	}
	commentedAt := extComment.Created.UnixMilli()

	// Mark comments as resolved if the PR is merged, otherwise they are unresolved.
	var resolved, resolvedBy *int64
	if pullReq.State == enum.PullReqStateMerged {
		resolved = &commentedAt
		resolvedBy = &commenter.ID
	}

	const maxLenText = 64 << 10 // This limit is deliberately larger than the limit in our API.
	if len(extComment.Body) > maxLenText {
		extComment.Body = extComment.Body[:maxLenText]
	}

	comment := &types.PullReqActivity{
		CreatedBy:   commenter.ID,
		Created:     commentedAt,
		Updated:     commentedAt,
		Edited:      commentedAt,
		Deleted:     nil,
		ParentID:    parentID,
		RepoID:      repo.ID,
		PullReqID:   pullReq.ID,
		Order:       int64(order),
		SubOrder:    int64(subOrder),
		ReplySeq:    int64(replySeq),
		Type:        enum.PullReqActivityTypeComment,
		Kind:        enum.PullReqActivityKindComment,
		Text:        extComment.Body,
		PayloadRaw:  json.RawMessage("{}"),
		Metadata:    nil,
		ResolvedBy:  resolvedBy,
		Resolved:    resolved,
		CodeComment: nil,
		Mentions:    nil,
	}

	if cc := extComment.CodeComment; cc != nil && cc.HunkHeader != "" && extComment.ParentID == 0 {
		// a code comment must have a valid HunkHeader and must not be a reply
		hunkHeader, ok := parser.ParseDiffHunkHeader(cc.HunkHeader)
		if !ok {
			return nil, errors.InvalidArgument("Invalid hunk header for code comment: %s", cc.HunkHeader)
		}

		comment.Kind = enum.PullReqActivityKindChangeComment
		comment.Type = enum.PullReqActivityTypeCodeComment

		comment.CodeComment = &types.CodeCommentFields{
			Outdated:     cc.SourceSHA != pullReq.SourceSHA,
			MergeBaseSHA: cc.MergeBaseSHA,
			SourceSHA:    cc.SourceSHA,
			Path:         cc.Path,
			LineNew:      hunkHeader.NewLine,
			SpanNew:      hunkHeader.NewSpan,
			LineOld:      hunkHeader.OldLine,
			SpanOld:      hunkHeader.OldSpan,
		}

		sideNew := !strings.EqualFold(cc.Side, "OLD") // cc.Side can be either OLD or NEW
		_ = comment.SetPayload(&types.PullRequestActivityPayloadCodeComment{
			Title:        cc.CodeSnippet.Header,
			Lines:        cc.CodeSnippet.Lines,
			LineStartNew: sideNew,
			LineEndNew:   sideNew,
		})
	}

	// store the comment

	if err := r.pullReqActivityStore.Create(ctx, comment); err != nil {
		return nil, fmt.Errorf("failed to store the external comment ID=%d author: %w", extComment.ID, err)
	}

	// update the pull request's counter fields

	pullReq.CommentCount++
	if comment.IsBlocking() {
		pullReq.UnresolvedCount++
	}

	if pullReq.ActivitySeq < comment.Order {
		pullReq.ActivitySeq = comment.Order
	}

	return comment, nil
}

// createInfoComment creates an informational comment on the PR
// if any of the principals were replaced with the migrator.
func (r *repoImportState) createInfoComment(
	ctx context.Context,
	repo *types.Repository,
	pullReq *types.PullReq,
) (*types.PullReqActivity, error) {
	var unknownEmails []string
	for email := range r.unknownEmails[int(pullReq.Number)] {
		unknownEmails = append(unknownEmails, email)
	}
	now := time.Now().UnixMilli()
	text := fmt.Sprintf(InfoCommentMessage, r.migrator.UID, strings.Join(unknownEmails, ", "))
	comment := &types.PullReqActivity{
		CreatedBy:   r.migrator.ID,
		Created:     now,
		Updated:     now,
		Deleted:     nil,
		ParentID:    nil,
		RepoID:      repo.ID,
		PullReqID:   pullReq.ID,
		Order:       pullReq.ActivitySeq + 1,
		SubOrder:    0,
		ReplySeq:    0,
		Type:        enum.PullReqActivityTypeComment,
		Kind:        enum.PullReqActivityKindComment,
		Text:        text,
		PayloadRaw:  json.RawMessage("{}"),
		Metadata:    nil,
		ResolvedBy:  &r.migrator.ID,
		Resolved:    &now,
		CodeComment: nil,
		Mentions:    nil,
	}

	if err := r.pullReqActivityStore.Create(ctx, comment); err != nil {
		return nil, fmt.Errorf("failed to store the info comment author: %w", err)
	}

	pullReq.ActivitySeq++
	pullReq.CommentCount++

	return comment, nil
}

func (r *repoImportState) getPrincipalByEmail(
	ctx context.Context,
	emailAddress string,
	prNumber int,
	strict bool,
) (*types.Principal, error) {
	if principal, exists := r.principals[emailAddress]; exists {
		return principal, nil
	}

	principal, err := r.principalStore.FindByEmail(ctx, emailAddress)
	if err != nil && !errors.Is(err, gitness_store.ErrResourceNotFound) {
		return nil, fmt.Errorf("failed to load principal by email: %w", err)
	}

	if err == nil {
		r.principals[emailAddress] = principal
		return principal, nil
	}

	if strict {
		return nil, fmt.Errorf(
			"could not find principal by email %s and automatic replacing unknown prinicapls is disabled: %w",
			emailAddress, err)
	}

	// ignore not found emails if is not strict
	if _, exists := r.unknownEmails[prNumber]; !exists {
		r.unknownEmails[prNumber] = make(map[string]bool, 0)
	}

	if _, ok := r.unknownEmails[prNumber][emailAddress]; !ok && len(r.unknownEmails[prNumber]) < MaxNumberOfUnknownEmails {
		r.unknownEmails[prNumber][emailAddress] = true
	}

	return &r.migrator, nil
}

func (r *repoImportState) assignLabels(
	ctx context.Context,
	spaceID int64,
	pullreq *types.PullReq,
	labels []ExternalLabel,
) error {
	if len(labels) == 0 {
		return nil
	}
	now := time.Now().UnixMilli()
	for _, l := range labels {
		var label *types.Label
		var err error
		labelID, found := r.labels[l.Name]
		if !found {
			label, err = r.labelStore.Find(ctx, &spaceID, nil, l.Name)
			if errors.Is(err, gitness_store.ErrResourceNotFound) {
				label, err = r.defineLabel(ctx, spaceID, l)
				if err != nil {
					return fmt.Errorf("failed to define label: %w", err)
				}
			} else if err != nil {
				return fmt.Errorf("failed to find the label with key %s in space %d: %w", l.Name, spaceID, err)
			}

			r.labels[l.Name], labelID = label.ID, label.ID
		}

		var valueID *int64
		valueID, found = r.labelValues[labelID][l.Value]
		if !found && l.Value != "" {
			var labelValue *types.LabelValue
			labelValue, err = r.labelValueStore.FindByLabelID(ctx, labelID, l.Value)
			if errors.Is(err, gitness_store.ErrResourceNotFound) {
				labelValue, err = r.defineLabelValue(ctx, labelID, l.Value)
				if err != nil {
					return fmt.Errorf("failed to define label values: %w", err)
				}
			} else if err != nil {
				return fmt.Errorf("failed to find the label with value %s and key %s in space %d: %w",
					l.Value, l.Name, spaceID, err)
			}

			valueID = &labelValue.ID
		}

		pullReqLabel := &types.PullReqLabel{
			PullReqID: pullreq.ID,
			LabelID:   labelID,
			ValueID:   valueID,
			Created:   now,
			Updated:   now,
			CreatedBy: r.migrator.ID,
			UpdatedBy: r.migrator.ID,
		}

		err = r.pullReqLabelAssignmentStore.Assign(ctx, pullReqLabel)
		if err != nil {
			return fmt.Errorf("failed to assign label %s to pull request: %w", l.Name, err)
		}
		pullreq.ActivitySeq++
	}

	return nil
}

func (r *repoImportState) defineLabel(
	ctx context.Context,
	spaceID int64,
	extLabel ExternalLabel,
) (*types.Label, error) {
	if r.scope == 0 {
		spaceIDs, err := r.spaceStore.GetAncestorIDs(ctx, spaceID)
		if err != nil {
			return nil, fmt.Errorf("failed to get space ids hierarchy: %w", err)
		}

		r.scope = int64(len(spaceIDs))
	}

	labelIn, err := convertLabelWithSanitization(ctx, r.migrator, spaceID, r.scope, extLabel)
	if err != nil {
		return nil, fmt.Errorf("failed to sanitize and convert external label input: %w", err)
	}

	label, err := r.labelStore.Find(ctx, &spaceID, nil, labelIn.Key)
	if errors.Is(err, gitness_store.ErrResourceNotFound) {
		err = r.labelStore.Define(ctx, labelIn)
		if err != nil {
			return nil, fmt.Errorf("failed to define and find the label: %w", err)
		}
		return labelIn, nil
	}
	if err != nil {
		return nil, fmt.Errorf("failed to define and find the label: %w", err)
	}

	return label, nil
}

func (r *repoImportState) defineLabelValue(
	ctx context.Context,
	labelID int64,
	value string,
) (*types.LabelValue, error) {
	valueIn := &types.DefineValueInput{
		Value: value,
		Color: defaultLabelValueColor,
	}
	if err := valueIn.Sanitize(); err != nil {
		return nil, fmt.Errorf("failed to sanitize external label value input: %w", err)
	}

	if _, exists := r.labelValues[labelID]; !exists {
		r.labelValues[labelID] = make(map[string]*int64)
	}

	labelValue, err := r.labelValueStore.FindByLabelID(ctx, labelID, valueIn.Value)
	if err == nil {
		r.labelValues[labelID][labelValue.Value] = &labelValue.ID
		return labelValue, nil
	}

	if !errors.Is(err, gitness_store.ErrResourceNotFound) {
		return nil, fmt.Errorf("failed to fine label value: %w", err)
	}

	// define the label value if not exists
	now := time.Now().UnixMilli()
	labelValue = &types.LabelValue{
		LabelID:   labelID,
		Value:     valueIn.Value,
		Color:     defaultLabelValueColor,
		Created:   now,
		Updated:   now,
		CreatedBy: r.migrator.ID,
		UpdatedBy: r.migrator.ID,
	}
	err = r.labelValueStore.Define(ctx, labelValue)
	if err != nil {
		return nil, fmt.Errorf("failed to define label value: %w", err)
	}
	_, err = r.labelStore.IncrementValueCount(ctx, labelID, 1)
	if err != nil {
		return nil, fmt.Errorf("failed to update label value count: %w", err)
	}

	r.labelValues[labelID][labelValue.Value] = &labelValue.ID
	return labelValue, nil
}

func timestampMillis(t time.Time, def int64) int64 {
	if t.IsZero() {
		return def
	}

	return t.UnixMilli()
}

func generateThreads(extComments []ExternalComment) []*externalCommentThread {
	extCommentParents := make(map[int]int, len(extComments))
	extCommentMap := make(map[int]ExternalComment, len(extComments))
	for _, extComment := range extComments {
		extCommentParents[extComment.ID] = extComment.ParentID
		extCommentMap[extComment.ID] = extComment
	}

	// Make flat list of reply comment IDs: create map[topLevelCommentID]->[]commentID
	extCommentIDReplyMap := make(map[int][]int)
	for _, extComment := range extComments {
		topLevelParentID := getTopLevelParentID(extComment.ID, extCommentParents)
		if topLevelParentID < 0 {
			continue
		}
		if topLevelParentID == extComment.ID {
			// Make sure the item with topLevelParentID exist in the map, at least as a nil entry.
			extCommentIDReplyMap[topLevelParentID] = extCommentIDReplyMap[topLevelParentID] //nolint:staticcheck
			continue
		}
		extCommentIDReplyMap[topLevelParentID] = append(extCommentIDReplyMap[topLevelParentID], extComment.ID)
	}

	countTopLevel := len(extCommentIDReplyMap)
	if countTopLevel == 0 {
		return nil
	}

	extCommentThreads := make([]*externalCommentThread, 0, countTopLevel)
	for topLevelID, replyIDs := range extCommentIDReplyMap {
		expReplyComments := make([]ExternalComment, len(replyIDs))
		for i, replyID := range replyIDs {
			expReplyComments[i] = extCommentMap[replyID]
		}
		thread := &externalCommentThread{
			TopLevel: extCommentMap[topLevelID],
			Replies:  expReplyComments,
		}
		extCommentThreads = append(extCommentThreads, thread)
	}

	// order top level comments

	sort.Slice(extCommentThreads, func(i, j int) bool {
		created1 := extCommentThreads[i].TopLevel.Created
		created2 := extCommentThreads[j].TopLevel.Created
		return created1.Before(created2)
	})

	// order reply comments

	for _, thread := range extCommentThreads {
		sort.Slice(thread.Replies, func(i, j int) bool {
			created1 := thread.Replies[i].Created
			created2 := thread.Replies[j].Created
			return created1.Before(created2)
		})
	}

	return extCommentThreads
}

func getTopLevelParentID(id int, tree map[int]int) int {
	const maxDepth = 20
	for currID, depth := id, 0; depth < maxDepth; depth++ {
		parentID := tree[currID]
		if parentID == 0 {
			return currID
		}

		currID = parentID
	}

	return -1
}
