package service

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"regexp"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/pingcap/agent-git-service/internal/db"
	"github.com/pingcap/agent-git-service/internal/gitstore"

	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

const (
	AsyncMergePending   = "pending"
	AsyncMergeMerging   = "merging"
	AsyncMergeCascading = "cascading"
	AsyncMergeMerged    = "merged"
	AsyncMergeEnqueued  = "enqueued"
	AsyncMergeFailed    = "failed"

	AsyncMergeActionDefault = "default"
	AsyncMergeActionDirect  = "direct_merge"
	AsyncMergeActionQueue   = "merge_queue"

	asyncMergeRetention = 24 * time.Hour
)

var (
	recoverableAsyncMergeStatuses = []string{AsyncMergePending, AsyncMergeMerging, AsyncMergeCascading}
	activeAsyncMergeStatuses      = []string{AsyncMergePending, AsyncMergeMerging, AsyncMergeCascading, AsyncMergeEnqueued}
)

type SubmitAsyncMergeInput struct {
	CommitTitle   string
	CommitMessage string
	SHA           string
	MergeMethod   string
	MergeAction   string
}

type SubmitAsyncMergeResult struct {
	Request        db.AsyncMergeRequest
	Created        bool
	AlreadyMerged  bool
	AlreadyQueued  bool
	ExistingActive bool
}

type AsyncMergeNotReadyError struct {
	Message string
}

func (e *AsyncMergeNotReadyError) Error() string { return e.Message }

func (s *Service) SubmitAsyncMerge(ctx context.Context, repoFullName string, number int, in SubmitAsyncMergeInput) (SubmitAsyncMergeResult, error) {
	actor, err := s.GetCurrentUser(ctx)
	if err != nil {
		return SubmitAsyncMergeResult{}, fmt.Errorf("authentication required for async merge: %w", err)
	}
	pr, err := s.GetPR(ctx, repoFullName, number)
	if err != nil {
		return SubmitAsyncMergeResult{}, err
	}
	if err := s.requireRepoPermission(ctx, pr.RepositoryID, RepoPermissionWrite); err != nil {
		return SubmitAsyncMergeResult{}, err
	}
	if pr.Merged {
		return SubmitAsyncMergeResult{Request: db.AsyncMergeRequest{
			Status: AsyncMergeMerged, ResultSHA: pr.MergeCommitSHA,
		}, AlreadyMerged: true}, nil
	}
	if pr.State != db.StateOpen {
		return SubmitAsyncMergeResult{}, &AsyncMergeNotReadyError{Message: "Pull request is closed."}
	}
	if pr.Draft {
		return SubmitAsyncMergeResult{}, &AsyncMergeNotReadyError{Message: "Pull request is a draft."}
	}
	method := strings.ToLower(strings.TrimSpace(in.MergeMethod))
	if method == "" {
		method = "merge"
	}
	if method != "merge" && method != "squash" && method != "rebase" {
		return SubmitAsyncMergeResult{}, fmt.Errorf("%w: merge_method must be merge, squash, or rebase", ErrValidation)
	}
	requestedAction := strings.ToLower(strings.TrimSpace(in.MergeAction))
	if requestedAction == "" {
		requestedAction = AsyncMergeActionDefault
	}
	if requestedAction != AsyncMergeActionDefault && requestedAction != AsyncMergeActionDirect && requestedAction != AsyncMergeActionQueue {
		return SubmitAsyncMergeResult{}, fmt.Errorf("%w: merge_action must be default, direct_merge, or merge_queue", ErrValidation)
	}
	expectedSHA := strings.TrimSpace(in.SHA)
	if expectedSHA == "" {
		expectedSHA = pr.HeadSHA
	} else if !strings.EqualFold(expectedSHA, pr.HeadSHA) {
		return SubmitAsyncMergeResult{}, fmt.Errorf("%w: sha does not match the current pull request head", ErrValidation)
	}

	var result SubmitAsyncMergeResult
	var enqueuedQueueID uint
	var enqueuedPullRequestIDs []uint
	var effectiveAction string
	err = s.Transaction(ctx, func(txCtx context.Context, tx *gorm.DB) error {
		if err := lockRepoForStackTopology(tx, pr.RepositoryID); err != nil {
			return err
		}
		lockedPR, err := s.lockPullRequestForMergeQueueTx(tx, pr.ID)
		if err != nil {
			return err
		}
		if lockedPR.Merged {
			result = SubmitAsyncMergeResult{Request: db.AsyncMergeRequest{
				Status: AsyncMergeMerged, ResultSHA: lockedPR.MergeCommitSHA,
			}, AlreadyMerged: true}
			return nil
		}
		if lockedPR.State != db.StateOpen {
			return &AsyncMergeNotReadyError{Message: "Pull request is closed."}
		}
		if lockedPR.Draft {
			return &AsyncMergeNotReadyError{Message: "Pull request is a draft."}
		}
		if !strings.EqualFold(expectedSHA, lockedPR.HeadSHA) {
			return fmt.Errorf("%w: sha does not match the current pull request head", ErrValidation)
		}
		target, err := s.mergeQueueTargetForPullRequestDB(tx, lockedPR, true)
		if err != nil {
			return err
		}
		_, queueRequired, err := s.MergeQueueConfigurationForBranch(txCtx, lockedPR.RepositoryID, target.BaseRef)
		if err != nil {
			return err
		}
		effectiveAction = requestedAction
		if effectiveAction == AsyncMergeActionDefault {
			if queueRequired {
				effectiveAction = AsyncMergeActionQueue
			} else {
				effectiveAction = AsyncMergeActionDirect
			}
		}
		if effectiveAction == AsyncMergeActionQueue && !queueRequired {
			return fmt.Errorf("%w: merge queue is not configured for %s", ErrValidation, target.BaseRef)
		}
		var existing db.AsyncMergeRequest
		err = tx.Where("pull_request_id = ? AND status IN ?", lockedPR.ID, activeAsyncMergeStatuses).
			Order("created_at DESC").First(&existing).Error
		if err == nil {
			now := time.Now().UTC()
			renewed, renewErr := renewAsyncMergeRetention(tx, existing.ID, activeAsyncMergeStatuses, now)
			if renewErr != nil {
				return renewErr
			}
			if !renewed {
				return fmt.Errorf("%w: async merge request changed state during submission", ErrConflict)
			}
			existing.ExpiresAt = now.Add(asyncMergeRetention)
			result = SubmitAsyncMergeResult{Request: existing, ExistingActive: true}
			return nil
		}
		if !errors.Is(err, gorm.ErrRecordNotFound) {
			return err
		}
		now := time.Now().UTC()
		var queuedEntry db.MergeQueueEntry
		err = tx.Where("pull_request_id = ? AND state IN ?", lockedPR.ID, activeMergeQueueEntryStates).First(&queuedEntry).Error
		if err == nil {
			result = SubmitAsyncMergeResult{Request: db.AsyncMergeRequest{Status: AsyncMergeEnqueued}, AlreadyQueued: true}
			return nil
		}
		if !errors.Is(err, gorm.ErrRecordNotFound) {
			return err
		}
		req := db.AsyncMergeRequest{
			UUID: uuid.NewString(), RepositoryID: lockedPR.RepositoryID, PullRequestID: lockedPR.ID,
			ActorID: actor.ID, Status: AsyncMergePending, MergeMethod: method, MergeAction: requestedAction,
			EffectiveMergeAction: effectiveAction,
			CommitTitle:          in.CommitTitle, CommitMessage: db.LargeText(in.CommitMessage), ExpectedHeadSHA: expectedSHA,
			CreatedAt: now, UpdatedAt: now, ExpiresAt: now.Add(asyncMergeRetention),
		}
		if err := tx.Create(&req).Error; err != nil {
			return err
		}
		result = SubmitAsyncMergeResult{Request: req, Created: true}
		if effectiveAction == AsyncMergeActionQueue {
			selected, err := s.mergeQueueSelectionForTarget(txCtx, tx, lockedPR, target, true)
			if err != nil {
				return err
			}
			entries, createdPullRequestIDs, err := s.enqueueAsyncMergeTx(tx, req, lockedPR, actor, target, selected)
			if err != nil {
				return err
			}
			enqueuedPullRequestIDs = createdPullRequestIDs
			if len(entries) == 0 {
				return fmt.Errorf("%w: no pull requests selected for merge queue", ErrValidation)
			}
			entryID := entries[len(entries)-1].ID
			enqueuedQueueID = entries[0].QueueID
			if err := tx.Model(&req).Updates(map[string]any{
				"status": AsyncMergeEnqueued, "merge_queue_entry_id": entryID,
				"expires_at": time.Now().UTC().Add(asyncMergeRetention),
			}).Error; err != nil {
				return err
			}
			req.Status = AsyncMergeEnqueued
			req.MergeQueueEntryID = &entryID
			result.Request = req
		}
		return nil
	})
	if err != nil {
		return SubmitAsyncMergeResult{}, err
	}
	if result.Created && result.Request.Status == AsyncMergePending {
		s.startAsyncMergeWorker(ctx, result.Request.ID, actor)
	}
	if result.Created && result.Request.Status == AsyncMergeEnqueued && enqueuedQueueID != 0 {
		s.notifyPullRequestQueueEvent(ctx, enqueuedPullRequestIDs, "enqueued")
		s.startMergeQueueProcessor(ctx, enqueuedQueueID, actor)
	}
	return result, nil
}

func (s *Service) GetAsyncMerge(ctx context.Context, repoFullName string, number int, requestUUID string) (db.AsyncMergeRequest, error) {
	repo, err := s.GetRepo(ctx, repoFullName)
	if err != nil {
		return db.AsyncMergeRequest{}, err
	}
	if err := s.requireRepoPermission(ctx, repo.ID, RepoPermissionWrite); err != nil {
		return db.AsyncMergeRequest{}, err
	}
	var req db.AsyncMergeRequest
	err = s.DBForCtx(ctx).Joins("JOIN pull_requests ON pull_requests.id = async_merge_requests.pull_request_id").
		Where("async_merge_requests.repository_id = ? AND pull_requests.number = ? AND async_merge_requests.uuid = ? AND async_merge_requests.expires_at > ?", repo.ID, number, requestUUID, time.Now().UTC()).
		First(&req).Error
	return req, wrapErr(err)
}

func (s *Service) startAsyncMergeWorker(parent context.Context, requestID uint, actor db.User) {
	_ = actor
	workerKey := s.workKey(parent, requestID)
	if _, loaded := s.asyncMergeWorkers.LoadOrStore(workerKey, struct{}{}); loaded {
		return
	}
	ctx, err := s.DetachContext(parent)
	if err != nil {
		s.asyncMergeWorkers.Delete(workerKey)
		slog.WarnContext(parent, "async merge worker not started", "request_id", requestID, "error", err)
		return
	}
	s.Wg.Add(1)
	go func() {
		defer s.Wg.Done()
		defer s.asyncMergeWorkers.Delete(workerKey)
		if err := s.executeAsyncMerge(ctx, requestID); err != nil {
			slog.WarnContext(ctx, "async pull request merge failed", "request_id", requestID, "error", err)
		}
	}()
}

func (s *Service) executeAsyncMerge(ctx context.Context, requestID uint) error {
	var req db.AsyncMergeRequest
	if err := s.DBForCtx(ctx).Preload("Actor").First(&req, requestID).Error; err != nil {
		return err
	}
	if req.Status != AsyncMergePending {
		return nil
	}
	pr, err := s.GetPRByID(ctx, req.PullRequestID)
	if err != nil {
		return s.failAsyncMerge(ctx, &req, err)
	}
	if !strings.EqualFold(pr.HeadSHA, req.ExpectedHeadSHA) {
		return s.failAsyncMerge(ctx, &req, fmt.Errorf("head SHA changed after submission"))
	}
	target, selected, err := s.mergeQueueSelectionForPullRequest(ctx, s.DBForCtx(ctx), pr, false)
	if err != nil {
		return s.failAsyncMerge(ctx, &req, err)
	}
	if req.EffectiveMergeAction == AsyncMergeActionDirect {
		if _, queueRequired, queueErr := s.MergeQueueConfigurationForBranch(ctx, pr.RepositoryID, target.BaseRef); queueErr != nil {
			return s.failAsyncMerge(ctx, &req, queueErr)
		} else if queueRequired {
			return s.failAsyncMerge(ctx, &req, fmt.Errorf("%w: branch requires merge queue", ErrInvalidState))
		}
	}

	ultimateBase := target.BaseRef
	stacked := target.StackID != 0
	for i := range selected {
		policyPR := selected[i]
		policyPR.Repository = pr.Repository
		policyPR.BaseRef = ultimateBase
		policyErr := s.enforceMergePolicy(ctx, req.Actor, &policyPR)
		if stacked {
			policyErr = s.enforceStackMergePolicy(ctx, req.Actor, &policyPR)
		}
		if policyErr != nil {
			return s.failAsyncMerge(ctx, &req, policyErr)
		}
	}

	var resultSHA string
	var memberSHAs []string
	if !stacked {
		if err := s.mergePRRecordWithActor(ctx, req.Actor, &pr, req.MergeMethod, asyncMergeCommitMessage(req, pr.Number)); err != nil {
			return s.failAsyncMerge(ctx, &req, err)
		}
		resultSHA = pr.MergeCommitSHA
	} else {
		plan, planErr := s.prepareDirectStackMergePlan(ctx, req, pr, ultimateBase, selected)
		if planErr != nil {
			return s.failAsyncMerge(ctx, &req, planErr)
		}
		encodedPlan, planErr := json.Marshal(plan)
		if planErr != nil {
			return s.failAsyncMerge(ctx, &req, planErr)
		}
		now := time.Now().UTC()
		transition := s.DBForCtx(ctx).Model(&db.AsyncMergeRequest{}).Where("id = ? AND status = ?", req.ID, AsyncMergePending).
			Updates(map[string]any{"status": AsyncMergeMerging, "merge_base_sha": plan.BaseSHA,
				"merge_plan_json": db.LargeText(encodedPlan), "updated_at": now,
				"expires_at": now.Add(asyncMergeRetention)})
		if transition.Error != nil {
			return transition.Error
		}
		if transition.RowsAffected != 1 {
			return nil
		}
		req.Status = AsyncMergeMerging
		req.MergeBaseSHA = plan.BaseSHA
		req.MergePlanJSON = db.LargeText(encodedPlan)
		resultSHA = plan.ExpectedFinalSHA
		gitStore, gitErr := s.GitForCtx(ctx)
		if gitErr != nil {
			return s.failAsyncMerge(ctx, &req, gitErr)
		}
		if pr.Repository.FullName != "" {
			mergeResult, mergeErr := gitStore.MergeStackAtomicallyDetailed(ctx, gitstore.StackMergeOptions{
				FullName: pr.Repository.FullName, BaseBranch: ultimateBase, Members: plan.gitMembers(),
				MergeMethod: req.MergeMethod, Committer: req.Actor.Login, Email: req.Actor.Email,
			})
			if mergeErr != nil {
				return s.failAsyncMerge(ctx, &req, mergeErr)
			}
			resultSHA = mergeResult.FinalSHA
			memberSHAs = mergeResult.MemberSHAs
		} else {
			memberSHAs = make([]string, len(selected))
			for i := range selected {
				memberSHAs[i] = selected[i].HeadSHA
			}
		}
		if err := s.finalizeDirectStackMerge(ctx, &req, plan, selected, resultSHA, memberSHAs); err != nil {
			return err
		}
		return nil
	}
	if err := s.DBForCtx(ctx).Model(&req).Updates(map[string]any{
		"status": AsyncMergeMerged, "result_sha": resultSHA, "failure_message": "", "updated_at": time.Now().UTC(),
		"expires_at": time.Now().UTC().Add(asyncMergeRetention),
	}).Error; err != nil {
		return err
	}
	ids := make([]uint, len(selected))
	for i := range selected {
		ids[i] = selected[i].ID
	}
	s.notifyPullRequestsMerged(ctx, ids)
	return nil
}

func selectedStackPrefix(stack PullRequestStackView, targetPRID uint) []db.PullRequest {
	var selected []db.PullRequest
	for _, entry := range stack.Entries {
		if !entry.PullRequest.Merged {
			selected = append(selected, entry.PullRequest)
		}
		if entry.PullRequestID == targetPRID {
			return selected
		}
	}
	return nil
}

func (s *Service) markStackPrefixMerged(ctx context.Context, prs []db.PullRequest, actorLogin string, memberSHAs []string) error {
	if len(memberSHAs) != len(prs) {
		return fmt.Errorf("exact merge SHA is required for every Stack member")
	}
	for i := range memberSHAs {
		if strings.TrimSpace(memberSHAs[i]) == "" {
			return fmt.Errorf("exact merge SHA is required for Stack member %d", i+1)
		}
	}
	now := time.Now().UTC()
	return s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		for i := range prs {
			if err := tx.Model(&db.PullRequest{}).Where("id = ? AND merged = ?", prs[i].ID, false).Updates(map[string]any{
				"merged": true, "merged_at": &now, "state": db.StateClosed, "closed_at": &now,
				"merge_commit_sha": memberSHAs[i], "merged_by_login": actorLogin,
				"auto_merge": false, "auto_merge_method": "", "auto_merge_commit_headline": "",
				"auto_merge_commit_body": "", "auto_merge_author_email": "",
				"auto_merge_expected_head_sha": "", "auto_merge_enabled_by_login": "",
			}).Error; err != nil {
				return err
			}
		}
		return nil
	})
}

func asyncMergeCommitMessage(req db.AsyncMergeRequest, number int) string {
	message := strings.TrimSpace(req.CommitTitle)
	if body := strings.TrimSpace(string(req.CommitMessage)); body != "" {
		if message != "" {
			message += "\n\n" + body
		} else {
			message = body
		}
	}
	if message == "" {
		message = fmt.Sprintf("Merge pull request #%d", number)
	}
	return message
}

func (s *Service) failAsyncMerge(ctx context.Context, req *db.AsyncMergeRequest, cause error) error {
	if req != nil {
		_ = s.DBForCtx(ctx).Model(req).Updates(map[string]any{
			"status": AsyncMergeFailed, "failure_message": cause.Error(), "updated_at": time.Now().UTC(),
			"expires_at": time.Now().UTC().Add(asyncMergeRetention),
		}).Error
	}
	return cause
}

// renewAsyncMergeRetention extends public result visibility without changing
// UpdatedAt. UpdatedAt records execution progress and recovery attempts, so an
// observing duplicate submission must not make abandoned work look fresh.
func renewAsyncMergeRetention(query *gorm.DB, requestID uint, statuses []string, now time.Time) (bool, error) {
	result := query.Model(&db.AsyncMergeRequest{}).
		Where("id = ? AND status IN ?", requestID, statuses).
		UpdateColumn("expires_at", now.Add(asyncMergeRetention))
	if result.Error != nil {
		return false, result.Error
	}
	return result.RowsAffected == 1, nil
}

// MergeQueueConfiguration is the public merge_queue ruleset parameters.
type MergeQueueConfiguration struct {
	CheckResponseTimeoutMinutes  int    `json:"check_response_timeout_minutes"`
	GroupingStrategy             string `json:"grouping_strategy"`
	MergeMethod                  string `json:"merge_method"`
	MaxEntriesToBuild            int    `json:"max_entries_to_build"`
	MaxEntriesToMerge            int    `json:"max_entries_to_merge"`
	MinEntriesToMerge            int    `json:"min_entries_to_merge"`
	MinEntriesToMergeWaitMinutes int    `json:"min_entries_to_merge_wait_minutes"`
}

func (s *Service) MergeQueueConfigurationForBranch(ctx context.Context, repoID uint, branch string) (MergeQueueConfiguration, bool, error) {
	var repo db.Repository
	if err := s.DBForCtx(ctx).Select("id", "default_branch").First(&repo, repoID).Error; err != nil {
		return MergeQueueConfiguration{}, false, wrapErr(err)
	}
	var rulesets []db.Ruleset
	if err := s.DBForCtx(ctx).Where("repository_id = ? AND target = ? AND enforcement = ?", repoID, "branch", "active").Find(&rulesets).Error; err != nil {
		return MergeQueueConfiguration{}, false, err
	}
	for _, ruleset := range rulesets {
		if !rulesetMatchesBranch(ruleset.ConditionsJSON, branch, repo.DefaultBranch) {
			continue
		}
		var rules []struct {
			Type       string                  `json:"type"`
			Parameters MergeQueueConfiguration `json:"parameters"`
		}
		if err := json.Unmarshal([]byte(ruleset.RulesJSON), &rules); err != nil {
			return MergeQueueConfiguration{}, false, fmt.Errorf("%w: invalid ruleset %d", ErrInvalidState, ruleset.ID)
		}
		for _, rule := range rules {
			if rule.Type == "merge_queue" {
				return rule.Parameters, true, nil
			}
		}
	}
	return MergeQueueConfiguration{}, false, nil
}

func rulesetMatchesBranch(raw, branch, defaultBranch string) bool {
	var conditions struct {
		RefName struct {
			Include []string `json:"include"`
			Exclude []string `json:"exclude"`
		} `json:"ref_name"`
	}
	if json.Unmarshal([]byte(raw), &conditions) != nil {
		return false
	}
	match := len(conditions.RefName.Include) == 0
	for _, include := range conditions.RefName.Include {
		if rulesetRefPatternMatches(include, branch, defaultBranch) {
			match = true
		}
	}
	for _, exclude := range conditions.RefName.Exclude {
		if rulesetRefPatternMatches(exclude, branch, defaultBranch) {
			return false
		}
	}
	return match
}

func rulesetRefPatternMatches(pattern, branch, defaultBranch string) bool {
	switch pattern {
	case "~ALL":
		return true
	case "~DEFAULT_BRANCH":
		return branch == defaultBranch
	}
	return gitHubFnmatch(pattern, branch) || gitHubFnmatch(pattern, "refs/heads/"+branch)
}

// gitHubFnmatch implements the File::FNM_PATHNAME subset documented for
// ruleset ref-name conditions: '*' does not cross '/', while '**' does.
func gitHubFnmatch(pattern, value string) bool {
	var expression strings.Builder
	expression.WriteByte('^')
	for i := 0; i < len(pattern); {
		switch pattern[i] {
		case '*':
			if i+1 < len(pattern) && pattern[i+1] == '*' {
				i += 2
				for i < len(pattern) && pattern[i] == '*' {
					i++
				}
				if i < len(pattern) && pattern[i] == '/' {
					expression.WriteString("(?:.*/)?")
					i++
				} else {
					expression.WriteString(".*")
				}
			} else {
				expression.WriteString("[^/]*")
				i++
			}
		case '?':
			expression.WriteString("[^/]")
			i++
		case '[':
			end := strings.IndexByte(pattern[i+1:], ']')
			if end < 0 {
				return false
			}
			end += i + 1
			set := pattern[i+1 : end]
			if set == "" || strings.HasPrefix(set, "^") || strings.HasPrefix(set, "!") {
				return false
			}
			expression.WriteByte('[')
			expression.WriteString(set)
			expression.WriteByte(']')
			i = end + 1
		default:
			expression.WriteString(regexp.QuoteMeta(string(pattern[i])))
			i++
		}
	}
	expression.WriteByte('$')
	matched, err := regexp.MatchString(expression.String(), value)
	return err == nil && matched
}

func (s *Service) enqueueAsyncMergeTx(tx *gorm.DB, req db.AsyncMergeRequest, pullRequest db.PullRequest, actor db.User, target MergeQueueTarget, selected []db.PullRequest) ([]db.MergeQueueEntry, []uint, error) {
	queue := db.MergeQueue{RepositoryID: pullRequest.RepositoryID, BaseRef: target.BaseRef}
	if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&queue).Error; err != nil {
		return nil, nil, err
	}
	if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("repository_id = ? AND base_ref = ?", pullRequest.RepositoryID, target.BaseRef).First(&queue).Error; err != nil {
		return nil, nil, err
	}
	var maxPosition int
	if err := tx.Model(&db.MergeQueueEntry{}).Where("queue_id = ? AND state IN ?", queue.ID, activeMergeQueueEntryStates).
		Select("COALESCE(MAX(position), 0)").Scan(&maxPosition).Error; err != nil {
		return nil, nil, err
	}
	batchID := ""
	if target.StackID != 0 {
		batchID = uuid.NewString()
	}
	entries := make([]db.MergeQueueEntry, 0, len(selected))
	createdPullRequestIDs := make([]uint, 0, len(selected))
	nextPosition := maxPosition + 1
	for i, pr := range selected {
		if pr.Merged {
			continue
		}
		var existing db.MergeQueueEntry
		if err := tx.Where("pull_request_id = ? AND state IN ?", pr.ID, activeMergeQueueEntryStates).First(&existing).Error; err == nil {
			entries = append(entries, existing)
			continue
		} else if !errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil, err
		}
		var asyncRequestID *uint
		if req.ID != 0 {
			asyncRequestID = &req.ID
		}
		entry := db.MergeQueueEntry{
			QueueID: queue.ID, PullRequestID: pr.ID, AsyncRequestID: asyncRequestID,
			Position: nextPosition, State: mergeQueueEntryQueued, EnqueuedByID: actor.ID,
			HeadSHA: pr.HeadSHA, BaseSHA: pr.BaseSHA, StackBatchID: batchID, StackPosition: i + 1,
		}
		nextPosition++
		if err := tx.Create(&entry).Error; err != nil {
			if isDuplicateErr(err) {
				return nil, nil, fmt.Errorf("%w: pull request is already queued", ErrConflict)
			}
			return nil, nil, err
		}
		if err := tx.Create(&db.PullRequestMergeQueueEvent{
			PullRequestID: pr.ID, QueueID: queue.ID, QueueEntryID: entry.ID,
			ActorID: actor.ID, EventType: "added",
		}).Error; err != nil {
			return nil, nil, err
		}
		entries = append(entries, entry)
		createdPullRequestIDs = append(createdPullRequestIDs, pr.ID)
	}
	for i := range entries {
		if err := tx.Preload("PullRequest.Repository.Owner").Preload("PullRequest.HeadRepository.Owner").
			Preload("PullRequest.Author").Preload("EnqueuedBy").First(&entries[i], entries[i].ID).Error; err != nil {
			return nil, nil, err
		}
	}
	return entries, createdPullRequestIDs, nil
}
