// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package issues

import (
	"context"
	"errors"
	"fmt"
	"strings"

	"code.gitea.io/gitea/models/db"
	"code.gitea.io/gitea/models/organization"
	access_model "code.gitea.io/gitea/models/perm/access"
	repo_model "code.gitea.io/gitea/models/repo"
	"code.gitea.io/gitea/models/unit"
	user_model "code.gitea.io/gitea/models/user"
	"code.gitea.io/gitea/modules/git"
	"code.gitea.io/gitea/modules/references"
	api "code.gitea.io/gitea/modules/structs"
	"code.gitea.io/gitea/modules/timeutil"
	"code.gitea.io/gitea/modules/util"

	"xorm.io/builder"
)

// UpdateIssueCols updates cols of issue
func UpdateIssueCols(ctx context.Context, issue *Issue, cols ...string) error {
	_, err := db.GetEngine(ctx).ID(issue.ID).Cols(cols...).Update(issue)
	return err
}

// ErrIssueIsClosed is used when close a closed issue
type ErrIssueIsClosed struct {
	ID     int64
	RepoID int64
	Index  int64
	IsPull bool
}

// IsErrIssueIsClosed checks if an error is a ErrIssueIsClosed.
func IsErrIssueIsClosed(err error) bool {
	_, ok := err.(ErrIssueIsClosed)
	return ok
}

func (err ErrIssueIsClosed) Error() string {
	return fmt.Sprintf("%s [id: %d, repo_id: %d, index: %d] is already closed", util.Iif(err.IsPull, "Pull Request", "Issue"), err.ID, err.RepoID, err.Index)
}

func SetIssueAsClosed(ctx context.Context, issue *Issue, doer *user_model.User, isMergePull bool) (*Comment, error) {
	if issue.IsClosed {
		return nil, ErrIssueIsClosed{
			ID:     issue.ID,
			RepoID: issue.RepoID,
			Index:  issue.Index,
			IsPull: issue.IsPull,
		}
	}

	// Check for open dependencies
	if issue.Repo.IsDependenciesEnabled(ctx) {
		// only check if dependencies are enabled and we're about to close an issue, otherwise reopening an issue would fail when there are unsatisfied dependencies
		noDeps, err := IssueNoDependenciesLeft(ctx, issue)
		if err != nil {
			return nil, err
		}

		if !noDeps {
			return nil, ErrDependenciesLeft{issue.ID}
		}
	}

	issue.IsClosed = true
	issue.ClosedUnix = timeutil.TimeStampNow()

	if cnt, err := db.GetEngine(ctx).ID(issue.ID).Cols("is_closed", "closed_unix").
		Where("is_closed = ?", false).
		Update(issue); err != nil {
		return nil, err
	} else if cnt != 1 {
		return nil, ErrIssueAlreadyChanged
	}

	return updateIssueNumbers(ctx, issue, doer, util.Iif(isMergePull, CommentTypeMergePull, CommentTypeClose))
}

// ErrIssueIsOpen is used when reopen an opened issue
type ErrIssueIsOpen struct {
	ID     int64
	RepoID int64
	IsPull bool
	Index  int64
}

// IsErrIssueIsOpen checks if an error is a ErrIssueIsOpen.
func IsErrIssueIsOpen(err error) bool {
	_, ok := err.(ErrIssueIsOpen)
	return ok
}

func (err ErrIssueIsOpen) Error() string {
	return fmt.Sprintf("%s [id: %d, repo_id: %d, index: %d] is already open", util.Iif(err.IsPull, "Pull Request", "Issue"), err.ID, err.RepoID, err.Index)
}

func setIssueAsReopen(ctx context.Context, issue *Issue, doer *user_model.User) (*Comment, error) {
	if !issue.IsClosed {
		return nil, ErrIssueIsOpen{
			ID:     issue.ID,
			RepoID: issue.RepoID,
			Index:  issue.Index,
			IsPull: issue.IsPull,
		}
	}

	issue.IsClosed = false
	issue.ClosedUnix = 0

	if cnt, err := db.GetEngine(ctx).ID(issue.ID).Cols("is_closed", "closed_unix").
		Where("is_closed = ?", true).
		Update(issue); err != nil {
		return nil, err
	} else if cnt != 1 {
		return nil, ErrIssueAlreadyChanged
	}

	return updateIssueNumbers(ctx, issue, doer, CommentTypeReopen)
}

func updateIssueNumbers(ctx context.Context, issue *Issue, doer *user_model.User, cmtType CommentType) (*Comment, error) {
	// Update issue count of labels
	if err := issue.LoadLabels(ctx); err != nil {
		return nil, err
	}
	for idx := range issue.Labels {
		if err := updateLabelCols(ctx, issue.Labels[idx], "num_issues", "num_closed_issue"); err != nil {
			return nil, err
		}
	}

	// Update issue count of milestone
	if issue.MilestoneID > 0 {
		if err := UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil {
			return nil, err
		}
	}

	// update repository's issue closed number
	switch cmtType {
	case CommentTypeClose, CommentTypeMergePull:
		// only increase closed count
		if err := IncrRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, false); err != nil {
			return nil, err
		}
	case CommentTypeReopen:
		// only decrease closed count
		if err := DecrRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, false, true); err != nil {
			return nil, err
		}
	default:
		return nil, fmt.Errorf("invalid comment type: %d", cmtType)
	}

	return CreateComment(ctx, &CreateCommentOptions{
		Type:  cmtType,
		Doer:  doer,
		Repo:  issue.Repo,
		Issue: issue,
	})
}

// CloseIssue changes issue status to closed.
func CloseIssue(ctx context.Context, issue *Issue, doer *user_model.User) (*Comment, error) {
	if err := issue.LoadRepo(ctx); err != nil {
		return nil, err
	}
	if err := issue.LoadPoster(ctx); err != nil {
		return nil, err
	}

	return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) {
		return SetIssueAsClosed(ctx, issue, doer, false)
	})
}

// ReopenIssue changes issue status to open.
func ReopenIssue(ctx context.Context, issue *Issue, doer *user_model.User) (*Comment, error) {
	if err := issue.LoadRepo(ctx); err != nil {
		return nil, err
	}
	if err := issue.LoadPoster(ctx); err != nil {
		return nil, err
	}

	return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) {
		return setIssueAsReopen(ctx, issue, doer)
	})
}

// ChangeIssueTitle changes the title of this issue, as the given user.
func ChangeIssueTitle(ctx context.Context, issue *Issue, doer *user_model.User, oldTitle string) (err error) {
	return db.WithTx(ctx, func(ctx context.Context) error {
		issue.Title = util.EllipsisDisplayString(issue.Title, 255)
		if err = UpdateIssueCols(ctx, issue, "name"); err != nil {
			return fmt.Errorf("updateIssueCols: %w", err)
		}

		if err = issue.LoadRepo(ctx); err != nil {
			return fmt.Errorf("loadRepo: %w", err)
		}

		opts := &CreateCommentOptions{
			Type:     CommentTypeChangeTitle,
			Doer:     doer,
			Repo:     issue.Repo,
			Issue:    issue,
			OldTitle: oldTitle,
			NewTitle: issue.Title,
		}
		if _, err = CreateComment(ctx, opts); err != nil {
			return fmt.Errorf("createComment: %w", err)
		}
		return issue.AddCrossReferences(ctx, doer, true)
	})
}

// ChangeIssueRef changes the branch of this issue, as the given user.
func ChangeIssueRef(ctx context.Context, issue *Issue, doer *user_model.User, oldRef string) (err error) {
	return db.WithTx(ctx, func(ctx context.Context) error {
		if err = UpdateIssueCols(ctx, issue, "ref"); err != nil {
			return fmt.Errorf("updateIssueCols: %w", err)
		}

		if err = issue.LoadRepo(ctx); err != nil {
			return fmt.Errorf("loadRepo: %w", err)
		}
		oldRefFriendly := strings.TrimPrefix(oldRef, git.BranchPrefix)
		newRefFriendly := strings.TrimPrefix(issue.Ref, git.BranchPrefix)

		opts := &CreateCommentOptions{
			Type:   CommentTypeChangeIssueRef,
			Doer:   doer,
			Repo:   issue.Repo,
			Issue:  issue,
			OldRef: oldRefFriendly,
			NewRef: newRefFriendly,
		}
		if _, err = CreateComment(ctx, opts); err != nil {
			return fmt.Errorf("createComment: %w", err)
		}
		return nil
	})
}

// AddDeletePRBranchComment adds delete branch comment for pull request issue
func AddDeletePRBranchComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issueID int64, branchName string) error {
	issue, err := GetIssueByID(ctx, issueID)
	if err != nil {
		return err
	}
	opts := &CreateCommentOptions{
		Type:   CommentTypeDeleteBranch,
		Doer:   doer,
		Repo:   repo,
		Issue:  issue,
		OldRef: branchName,
	}
	_, err = CreateComment(ctx, opts)
	return err
}

// UpdateIssueAttachments update attachments by UUIDs for the issue
func UpdateIssueAttachments(ctx context.Context, issueID int64, uuids []string) (err error) {
	return db.WithTx(ctx, func(ctx context.Context) error {
		attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
		if err != nil {
			return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err)
		}
		for i := range attachments {
			attachments[i].IssueID = issueID
			if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
				return fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err)
			}
		}
		return nil
	})
}

// ChangeIssueContent changes issue content, as the given user.
func ChangeIssueContent(ctx context.Context, issue *Issue, doer *user_model.User, content string, contentVersion int) (err error) {
	return db.WithTx(ctx, func(ctx context.Context) error {
		hasContentHistory, err := HasIssueContentHistory(ctx, issue.ID, 0)
		if err != nil {
			return fmt.Errorf("HasIssueContentHistory: %w", err)
		}
		if !hasContentHistory {
			if err = SaveIssueContentHistory(ctx, issue.PosterID, issue.ID, 0,
				issue.CreatedUnix, issue.Content, true); err != nil {
				return fmt.Errorf("SaveIssueContentHistory: %w", err)
			}
		}

		issue.Content = content
		issue.ContentVersion = contentVersion + 1

		affected, err := db.GetEngine(ctx).ID(issue.ID).Cols("content", "content_version").Where("content_version = ?", contentVersion).Update(issue)
		if err != nil {
			return err
		}
		if affected == 0 {
			return ErrIssueAlreadyChanged
		}

		if err = SaveIssueContentHistory(ctx, doer.ID, issue.ID, 0,
			timeutil.TimeStampNow(), issue.Content, false); err != nil {
			return fmt.Errorf("SaveIssueContentHistory: %w", err)
		}

		if err = issue.AddCrossReferences(ctx, doer, true); err != nil {
			return fmt.Errorf("addCrossReferences: %w", err)
		}
		return nil
	})
}

// NewIssueOptions represents the options of a new issue.
type NewIssueOptions struct {
	Repo        *repo_model.Repository
	Issue       *Issue
	LabelIDs    []int64
	Attachments []string // In UUID format.
}

// NewIssueWithIndex creates issue with given index
func NewIssueWithIndex(ctx context.Context, doer *user_model.User, opts NewIssueOptions) (err error) {
	e := db.GetEngine(ctx)
	opts.Issue.Title = strings.TrimSpace(opts.Issue.Title)

	if opts.Issue.MilestoneID > 0 {
		milestone, err := GetMilestoneByRepoID(ctx, opts.Issue.RepoID, opts.Issue.MilestoneID)
		if err != nil && !IsErrMilestoneNotExist(err) {
			return fmt.Errorf("getMilestoneByID: %w", err)
		}

		// Assume milestone is invalid and drop silently.
		opts.Issue.MilestoneID = 0
		if milestone != nil {
			opts.Issue.MilestoneID = milestone.ID
			opts.Issue.Milestone = milestone
		}
	}

	if opts.Issue.Index <= 0 {
		return errors.New("no issue index provided")
	}
	if opts.Issue.ID > 0 {
		return errors.New("issue exist")
	}

	if _, err := e.Insert(opts.Issue); err != nil {
		return err
	}

	if opts.Issue.MilestoneID > 0 {
		if err := UpdateMilestoneCounters(ctx, opts.Issue.MilestoneID); err != nil {
			return err
		}

		opts := &CreateCommentOptions{
			Type:           CommentTypeMilestone,
			Doer:           doer,
			Repo:           opts.Repo,
			Issue:          opts.Issue,
			OldMilestoneID: 0,
			MilestoneID:    opts.Issue.MilestoneID,
		}
		if _, err = CreateComment(ctx, opts); err != nil {
			return err
		}
	}

	// Update repository issue total count
	if err := IncrRepoIssueNumbers(ctx, opts.Repo.ID, opts.Issue.IsPull, true); err != nil {
		return err
	}

	if len(opts.LabelIDs) > 0 {
		// During the session, SQLite3 driver cannot handle retrieve objects after update something.
		// So we have to get all needed labels first.
		labels := make([]*Label, 0, len(opts.LabelIDs))
		if err = e.In("id", opts.LabelIDs).Find(&labels); err != nil {
			return fmt.Errorf("find all labels [label_ids: %v]: %w", opts.LabelIDs, err)
		}

		if err = opts.Issue.LoadPoster(ctx); err != nil {
			return err
		}

		for _, label := range labels {
			// Silently drop invalid labels.
			if label.RepoID != opts.Repo.ID && label.OrgID != opts.Repo.OwnerID {
				continue
			}

			if err = newIssueLabel(ctx, opts.Issue, label, opts.Issue.Poster); err != nil {
				return fmt.Errorf("addLabel [id: %d]: %w", label.ID, err)
			}
		}
	}

	if err = NewIssueUsers(ctx, opts.Repo, opts.Issue); err != nil {
		return err
	}

	if err := UpdateIssueAttachments(ctx, opts.Issue.ID, opts.Attachments); err != nil {
		return err
	}

	if err = opts.Issue.LoadAttributes(ctx); err != nil {
		return err
	}

	return opts.Issue.AddCrossReferences(ctx, doer, false)
}

// NewIssue creates new issue with labels for repository.
// The title will be cut off at 255 characters if it's longer than 255 characters.
func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *Issue, labelIDs []int64, uuids []string) (err error) {
	return db.WithTx(ctx, func(ctx context.Context) error {
		idx, err := db.GetNextResourceIndex(ctx, "issue_index", repo.ID)
		if err != nil {
			return fmt.Errorf("generate issue index failed: %w", err)
		}

		issue.Index = idx
		issue.Title = util.EllipsisDisplayString(issue.Title, 255)

		if err = NewIssueWithIndex(ctx, issue.Poster, NewIssueOptions{
			Repo:        repo,
			Issue:       issue,
			LabelIDs:    labelIDs,
			Attachments: uuids,
		}); err != nil {
			if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) || IsErrNewIssueInsert(err) {
				return err
			}
			return fmt.Errorf("newIssue: %w", err)
		}
		return nil
	})
}

// IncrRepoIssueNumbers increments repository issue numbers.
func IncrRepoIssueNumbers(ctx context.Context, repoID int64, isPull, totalOrClosed bool) error {
	dbSession := db.GetEngine(ctx)
	var colName string
	if totalOrClosed {
		colName = util.Iif(isPull, "num_pulls", "num_issues")
	} else {
		colName = util.Iif(isPull, "num_closed_pulls", "num_closed_issues")
	}
	_, err := dbSession.Incr(colName).ID(repoID).
		NoAutoCondition().NoAutoTime().
		Update(new(repo_model.Repository))
	return err
}

// DecrRepoIssueNumbers decrements repository issue numbers.
func DecrRepoIssueNumbers(ctx context.Context, repoID int64, isPull, includeTotal, includeClosed bool) error {
	if !includeTotal && !includeClosed {
		return fmt.Errorf("no numbers to decrease for repo id %d", repoID)
	}

	dbSession := db.GetEngine(ctx)
	if includeTotal {
		colName := util.Iif(isPull, "num_pulls", "num_issues")
		dbSession = dbSession.Decr(colName)
	}
	if includeClosed {
		closedColName := util.Iif(isPull, "num_closed_pulls", "num_closed_issues")
		dbSession = dbSession.Decr(closedColName)
	}
	_, err := dbSession.ID(repoID).
		NoAutoCondition().NoAutoTime().
		Update(new(repo_model.Repository))
	return err
}

// UpdateIssueMentions updates issue-user relations for mentioned users.
func UpdateIssueMentions(ctx context.Context, issueID int64, mentions []*user_model.User) error {
	if len(mentions) == 0 {
		return nil
	}
	ids := make([]int64, len(mentions))
	for i, u := range mentions {
		ids[i] = u.ID
	}
	if err := UpdateIssueUsersByMentions(ctx, issueID, ids); err != nil {
		return fmt.Errorf("UpdateIssueUsersByMentions: %w", err)
	}
	return nil
}

// UpdateIssueDeadline updates an issue deadline and adds comments. Setting a deadline to 0 means deleting it.
func UpdateIssueDeadline(ctx context.Context, issue *Issue, deadlineUnix timeutil.TimeStamp, doer *user_model.User) (err error) {
	// if the deadline hasn't changed do nothing
	if issue.DeadlineUnix == deadlineUnix {
		return nil
	}

	return db.WithTx(ctx, func(ctx context.Context) error {
		// Update the deadline
		if err = UpdateIssueCols(ctx, &Issue{ID: issue.ID, DeadlineUnix: deadlineUnix}, "deadline_unix"); err != nil {
			return err
		}

		// Make the comment
		if _, err = createDeadlineComment(ctx, doer, issue, deadlineUnix); err != nil {
			return fmt.Errorf("createRemovedDueDateComment: %w", err)
		}
		return nil
	})
}

// FindAndUpdateIssueMentions finds users mentioned in the given content string, and saves them in the database.
func FindAndUpdateIssueMentions(ctx context.Context, issue *Issue, doer *user_model.User, content string) (mentions []*user_model.User, err error) {
	rawMentions := references.FindAllMentionsMarkdown(content)
	mentions, err = ResolveIssueMentionsByVisibility(ctx, issue, doer, rawMentions)
	if err != nil {
		return nil, fmt.Errorf("UpdateIssueMentions [%d]: %w", issue.ID, err)
	}

	notBlocked := make([]*user_model.User, 0, len(mentions))
	for _, user := range mentions {
		if !user_model.IsUserBlockedBy(ctx, doer, user.ID) {
			notBlocked = append(notBlocked, user)
		}
	}
	mentions = notBlocked

	if err = UpdateIssueMentions(ctx, issue.ID, mentions); err != nil {
		return nil, fmt.Errorf("UpdateIssueMentions [%d]: %w", issue.ID, err)
	}
	return mentions, err
}

// ResolveIssueMentionsByVisibility returns the users mentioned in an issue, removing those that
// don't have access to reading it. Teams are expanded into their users, but organizations are ignored.
func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *user_model.User, mentions []string) (users []*user_model.User, err error) {
	if len(mentions) == 0 {
		return nil, nil
	}
	if err = issue.LoadRepo(ctx); err != nil {
		return nil, err
	}

	resolved := make(map[string]bool, 10)
	var mentionTeams []string

	if err := issue.Repo.LoadOwner(ctx); err != nil {
		return nil, err
	}

	repoOwnerIsOrg := issue.Repo.Owner.IsOrganization()
	if repoOwnerIsOrg {
		mentionTeams = make([]string, 0, 5)
	}

	resolved[doer.LowerName] = true
	for _, name := range mentions {
		name := strings.ToLower(name)
		if _, ok := resolved[name]; ok {
			continue
		}
		if repoOwnerIsOrg && strings.Contains(name, "/") {
			names := strings.Split(name, "/")
			if len(names) < 2 || names[0] != issue.Repo.Owner.LowerName {
				continue
			}
			mentionTeams = append(mentionTeams, names[1])
			resolved[name] = true
		} else {
			resolved[name] = false
		}
	}

	if issue.Repo.Owner.IsOrganization() && len(mentionTeams) > 0 {
		teams := make([]*organization.Team, 0, len(mentionTeams))
		if err := db.GetEngine(ctx).
			Join("INNER", "team_repo", "team_repo.team_id = team.id").
			Where("team_repo.repo_id=?", issue.Repo.ID).
			In("team.lower_name", mentionTeams).
			Find(&teams); err != nil {
			return nil, fmt.Errorf("find mentioned teams: %w", err)
		}
		if len(teams) != 0 {
			checked := make([]int64, 0, len(teams))
			unittype := unit.TypeIssues
			if issue.IsPull {
				unittype = unit.TypePullRequests
			}
			for _, team := range teams {
				if team.HasAdminAccess() {
					checked = append(checked, team.ID)
					resolved[issue.Repo.Owner.LowerName+"/"+team.LowerName] = true
					continue
				}
				has, err := db.GetEngine(ctx).Get(&organization.TeamUnit{OrgID: issue.Repo.Owner.ID, TeamID: team.ID, Type: unittype})
				if err != nil {
					return nil, fmt.Errorf("get team units (%d): %w", team.ID, err)
				}
				if has {
					checked = append(checked, team.ID)
					resolved[issue.Repo.Owner.LowerName+"/"+team.LowerName] = true
				}
			}
			if len(checked) != 0 {
				teamusers := make([]*user_model.User, 0, 20)
				if err := db.GetEngine(ctx).
					Join("INNER", "team_user", "team_user.uid = `user`.id").
					In("`team_user`.team_id", checked).
					And("`user`.is_active = ?", true).
					And("`user`.prohibit_login = ?", false).
					Find(&teamusers); err != nil {
					return nil, fmt.Errorf("get teams users: %w", err)
				}
				if len(teamusers) > 0 {
					users = make([]*user_model.User, 0, len(teamusers))
					for _, user := range teamusers {
						if already, ok := resolved[user.LowerName]; !ok || !already {
							users = append(users, user)
							resolved[user.LowerName] = true
						}
					}
				}
			}
		}
	}

	// Remove names already in the list to avoid querying the database if pending names remain
	mentionUsers := make([]string, 0, len(resolved))
	for name, already := range resolved {
		if !already {
			mentionUsers = append(mentionUsers, name)
		}
	}
	if len(mentionUsers) == 0 {
		return users, err
	}

	if users == nil {
		users = make([]*user_model.User, 0, len(mentionUsers))
	}

	unchecked := make([]*user_model.User, 0, len(mentionUsers))
	if err := db.GetEngine(ctx).
		Where("`user`.is_active = ?", true).
		And("`user`.prohibit_login = ?", false).
		In("`user`.lower_name", mentionUsers).
		Find(&unchecked); err != nil {
		return nil, fmt.Errorf("find mentioned users: %w", err)
	}
	for _, user := range unchecked {
		if already := resolved[user.LowerName]; already || user.IsOrganization() {
			continue
		}
		// Normal users must have read access to the referencing issue
		perm, err := access_model.GetIndividualUserRepoPermission(ctx, issue.Repo, user)
		if err != nil {
			return nil, fmt.Errorf("GetIndividualUserRepoPermission [%d]: %w", user.ID, err)
		}
		if !perm.CanReadIssuesOrPulls(issue.IsPull) {
			continue
		}
		users = append(users, user)
	}

	return users, err
}

// UpdateIssuesMigrationsByType updates all migrated repositories' issues from gitServiceType to replace originalAuthorID to posterID
func UpdateIssuesMigrationsByType(ctx context.Context, gitServiceType api.GitServiceType, originalAuthorID string, posterID int64) error {
	_, err := db.GetEngine(ctx).Table("issue").
		Where("repo_id IN (SELECT id FROM repository WHERE original_service_type = ?)", gitServiceType).
		And("original_author_id = ?", originalAuthorID).
		Update(map[string]any{
			"poster_id":          posterID,
			"original_author":    "",
			"original_author_id": 0,
		})
	return err
}

// UpdateReactionsMigrationsByType updates all migrated repositories' reactions from gitServiceType to replace originalAuthorID to posterID
func UpdateReactionsMigrationsByType(ctx context.Context, gitServiceType api.GitServiceType, originalAuthorID string, userID int64) error {
	_, err := db.GetEngine(ctx).Table("reaction").
		Where("original_author_id = ?", originalAuthorID).
		And(migratedIssueCond(gitServiceType)).
		Update(map[string]any{
			"user_id":            userID,
			"original_author":    "",
			"original_author_id": 0,
		})
	return err
}

func GetOrphanedIssueRepoIDs(ctx context.Context) ([]int64, error) {
	var repoIDs []int64
	if err := db.GetEngine(ctx).Table("issue").Distinct("issue.repo_id").
		Join("LEFT", "repository", "issue.repo_id=repository.id").
		Where(builder.IsNull{"repository.id"}).
		Find(&repoIDs); err != nil {
		return nil, err
	}
	return repoIDs, nil
}
