// Copyright 2020 The Gogs Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package db

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

	"github.com/gogs/git-module"
	api "github.com/gogs/go-gogs-client"
	jsoniter "github.com/json-iterator/go"
	"github.com/pkg/errors"
	"gorm.io/gorm"
	log "unknwon.dev/clog/v2"

	"gogs.io/gogs/internal/conf"
	"gogs.io/gogs/internal/lazyregexp"
	"gogs.io/gogs/internal/repoutil"
	"gogs.io/gogs/internal/strutil"
	"gogs.io/gogs/internal/testutil"
	"gogs.io/gogs/internal/tool"
)

// ActionsStore is the persistent interface for actions.
type ActionsStore interface {
	// CommitRepo creates actions for pushing commits to the repository. An action
	// with the type ActionDeleteBranch is created if the push deletes a branch; an
	// action with the type ActionCommitRepo is created for a regular push. If the
	// regular push also creates a new branch, then another action with type
	// ActionCreateBranch is created.
	CommitRepo(ctx context.Context, opts CommitRepoOptions) error
	// ListByOrganization returns actions of the organization viewable by the actor.
	// Results are paginated if `afterID` is given.
	ListByOrganization(ctx context.Context, orgID, actorID, afterID int64) ([]*Action, error)
	// ListByUser returns actions of the user viewable by the actor. Results are
	// paginated if `afterID` is given. The `isProfile` indicates whether repository
	// permissions should be considered.
	ListByUser(ctx context.Context, userID, actorID, afterID int64, isProfile bool) ([]*Action, error)
	// MergePullRequest creates an action for merging a pull request.
	MergePullRequest(ctx context.Context, doer, owner *User, repo *Repository, pull *Issue) error
	// MirrorSyncCreate creates an action for mirror synchronization of a new
	// reference.
	MirrorSyncCreate(ctx context.Context, owner *User, repo *Repository, refName string) error
	// MirrorSyncDelete creates an action for mirror synchronization of a reference
	// deletion.
	MirrorSyncDelete(ctx context.Context, owner *User, repo *Repository, refName string) error
	// MirrorSyncPush creates an action for mirror synchronization of pushed
	// commits.
	MirrorSyncPush(ctx context.Context, opts MirrorSyncPushOptions) error
	// NewRepo creates an action for creating a new repository. The action type
	// could be ActionCreateRepo or ActionForkRepo based on whether the repository
	// is a fork.
	NewRepo(ctx context.Context, doer, owner *User, repo *Repository) error
	// PushTag creates an action for pushing tags to the repository. An action with
	// the type ActionDeleteTag is created if the push deletes a tag. Otherwise, an
	// action with the type ActionPushTag is created for a regular push.
	PushTag(ctx context.Context, opts PushTagOptions) error
	// RenameRepo creates an action for renaming a repository.
	RenameRepo(ctx context.Context, doer, owner *User, oldRepoName string, repo *Repository) error
	// TransferRepo creates an action for transferring a repository to a new owner.
	TransferRepo(ctx context.Context, doer, oldOwner, newOwner *User, repo *Repository) error
}

var Actions ActionsStore

var _ ActionsStore = (*actions)(nil)

type actions struct {
	*gorm.DB
}

// NewActionsStore returns a persistent interface for actions with given
// database connection.
func NewActionsStore(db *gorm.DB) ActionsStore {
	return &actions{DB: db}
}

func (db *actions) listByOrganization(ctx context.Context, orgID, actorID, afterID int64) *gorm.DB {
	/*
		Equivalent SQL for PostgreSQL:

		SELECT * FROM "action"
		WHERE
			user_id = @userID
		AND (@skipAfter OR id < @afterID)
		AND repo_id IN (
			SELECT repository.id FROM "repository"
			JOIN team_repo ON repository.id = team_repo.repo_id
			WHERE team_repo.team_id IN (
					SELECT team_id FROM "team_user"
					WHERE
						team_user.org_id = @orgID AND uid = @actorID)
					OR  (repository.is_private = FALSE AND repository.is_unlisted = FALSE)
			)
		ORDER BY id DESC
		LIMIT @limit
	*/
	return db.WithContext(ctx).
		Where("user_id = ?", orgID).
		Where(db.
			// Not apply when afterID is not given
			Where("?", afterID <= 0).
			Or("id < ?", afterID),
		).
		Where("repo_id IN (?)", db.
			Select("repository.id").
			Table("repository").
			Joins("JOIN team_repo ON repository.id = team_repo.repo_id").
			Where("team_repo.team_id IN (?)", db.
				Select("team_id").
				Table("team_user").
				Where("team_user.org_id = ? AND uid = ?", orgID, actorID),
			).
			Or("repository.is_private = ? AND repository.is_unlisted = ?", false, false),
		).
		Limit(conf.UI.User.NewsFeedPagingNum).
		Order("id DESC")
}

func (db *actions) ListByOrganization(ctx context.Context, orgID, actorID, afterID int64) ([]*Action, error) {
	actions := make([]*Action, 0, conf.UI.User.NewsFeedPagingNum)
	return actions, db.listByOrganization(ctx, orgID, actorID, afterID).Find(&actions).Error
}

func (db *actions) listByUser(ctx context.Context, userID, actorID, afterID int64, isProfile bool) *gorm.DB {
	/*
		Equivalent SQL for PostgreSQL:

		SELECT * FROM "action"
		WHERE
			user_id = @userID
		AND (@skipAfter OR id < @afterID)
		AND (@includePrivate OR (is_private = FALSE AND act_user_id = @actorID))
		ORDER BY id DESC
		LIMIT @limit
	*/
	return db.WithContext(ctx).
		Where("user_id = ?", userID).
		Where(db.
			// Not apply when afterID is not given
			Where("?", afterID <= 0).
			Or("id < ?", afterID),
		).
		Where(db.
			// Not apply when in not profile page or the user is viewing own profile
			Where("?", !isProfile || actorID == userID).
			Or("is_private = ? AND act_user_id = ?", false, userID),
		).
		Limit(conf.UI.User.NewsFeedPagingNum).
		Order("id DESC")
}

func (db *actions) ListByUser(ctx context.Context, userID, actorID, afterID int64, isProfile bool) ([]*Action, error) {
	actions := make([]*Action, 0, conf.UI.User.NewsFeedPagingNum)
	return actions, db.listByUser(ctx, userID, actorID, afterID, isProfile).Find(&actions).Error
}

// notifyWatchers creates rows in action table for watchers who are able to see the action.
func (db *actions) notifyWatchers(ctx context.Context, act *Action) error {
	watches, err := NewReposStore(db.DB).ListWatches(ctx, act.RepoID)
	if err != nil {
		return errors.Wrap(err, "list watches")
	}

	// Clone returns a deep copy of the action with UserID assigned
	clone := func(userID int64) *Action {
		tmp := *act
		tmp.UserID = userID
		return &tmp
	}

	// Plus one for the actor
	actions := make([]*Action, 0, len(watches)+1)
	actions = append(actions, clone(act.ActUserID))

	for _, watch := range watches {
		if act.ActUserID == watch.UserID {
			continue
		}
		actions = append(actions, clone(watch.UserID))
	}

	return db.Create(actions).Error
}

func (db *actions) NewRepo(ctx context.Context, doer, owner *User, repo *Repository) error {
	opType := ActionCreateRepo
	if repo.IsFork {
		opType = ActionForkRepo
	}

	return db.notifyWatchers(ctx,
		&Action{
			ActUserID:    doer.ID,
			ActUserName:  doer.Name,
			OpType:       opType,
			RepoID:       repo.ID,
			RepoUserName: owner.Name,
			RepoName:     repo.Name,
			IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
		},
	)
}

func (db *actions) RenameRepo(ctx context.Context, doer, owner *User, oldRepoName string, repo *Repository) error {
	return db.notifyWatchers(ctx,
		&Action{
			ActUserID:    doer.ID,
			ActUserName:  doer.Name,
			OpType:       ActionRenameRepo,
			RepoID:       repo.ID,
			RepoUserName: owner.Name,
			RepoName:     repo.Name,
			IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
			Content:      oldRepoName,
		},
	)
}

func (db *actions) mirrorSyncAction(ctx context.Context, opType ActionType, owner *User, repo *Repository, refName string, content []byte) error {
	return db.notifyWatchers(ctx,
		&Action{
			ActUserID:    owner.ID,
			ActUserName:  owner.Name,
			OpType:       opType,
			Content:      string(content),
			RepoID:       repo.ID,
			RepoUserName: owner.Name,
			RepoName:     repo.Name,
			RefName:      refName,
			IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
		},
	)
}

type MirrorSyncPushOptions struct {
	Owner       *User
	Repo        *Repository
	RefName     string
	OldCommitID string
	NewCommitID string
	Commits     *PushCommits
}

func (db *actions) MirrorSyncPush(ctx context.Context, opts MirrorSyncPushOptions) error {
	if conf.UI.FeedMaxCommitNum > 0 && len(opts.Commits.Commits) > conf.UI.FeedMaxCommitNum {
		opts.Commits.Commits = opts.Commits.Commits[:conf.UI.FeedMaxCommitNum]
	}

	apiCommits, err := opts.Commits.APIFormat(ctx,
		NewUsersStore(db.DB),
		repoutil.RepositoryPath(opts.Owner.Name, opts.Repo.Name),
		repoutil.HTMLURL(opts.Owner.Name, opts.Repo.Name),
	)
	if err != nil {
		return errors.Wrap(err, "convert commits to API format")
	}

	opts.Commits.CompareURL = repoutil.CompareCommitsPath(opts.Owner.Name, opts.Repo.Name, opts.OldCommitID, opts.NewCommitID)
	apiPusher := opts.Owner.APIFormat()
	err = PrepareWebhooks(
		opts.Repo,
		HOOK_EVENT_PUSH,
		&api.PushPayload{
			Ref:        opts.RefName,
			Before:     opts.OldCommitID,
			After:      opts.NewCommitID,
			CompareURL: conf.Server.ExternalURL + opts.Commits.CompareURL,
			Commits:    apiCommits,
			Repo:       opts.Repo.APIFormat(opts.Owner),
			Pusher:     apiPusher,
			Sender:     apiPusher,
		},
	)
	if err != nil {
		return errors.Wrap(err, "prepare webhooks")
	}

	data, err := jsoniter.Marshal(opts.Commits)
	if err != nil {
		return errors.Wrap(err, "marshal JSON")
	}

	return db.mirrorSyncAction(ctx, ActionMirrorSyncPush, opts.Owner, opts.Repo, opts.RefName, data)
}

func (db *actions) MirrorSyncCreate(ctx context.Context, owner *User, repo *Repository, refName string) error {
	return db.mirrorSyncAction(ctx, ActionMirrorSyncCreate, owner, repo, refName, nil)
}

func (db *actions) MirrorSyncDelete(ctx context.Context, owner *User, repo *Repository, refName string) error {
	return db.mirrorSyncAction(ctx, ActionMirrorSyncDelete, owner, repo, refName, nil)
}

func (db *actions) MergePullRequest(ctx context.Context, doer, owner *User, repo *Repository, pull *Issue) error {
	return db.notifyWatchers(ctx,
		&Action{
			ActUserID:    doer.ID,
			ActUserName:  doer.Name,
			OpType:       ActionMergePullRequest,
			Content:      fmt.Sprintf("%d|%s", pull.Index, pull.Title),
			RepoID:       repo.ID,
			RepoUserName: owner.Name,
			RepoName:     repo.Name,
			IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
		},
	)
}

func (db *actions) TransferRepo(ctx context.Context, doer, oldOwner, newOwner *User, repo *Repository) error {
	return db.notifyWatchers(ctx,
		&Action{
			ActUserID:    doer.ID,
			ActUserName:  doer.Name,
			OpType:       ActionTransferRepo,
			RepoID:       repo.ID,
			RepoUserName: newOwner.Name,
			RepoName:     repo.Name,
			IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
			Content:      oldOwner.Name + "/" + repo.Name,
		},
	)
}

var (
	// Same as GitHub, see https://docs.github.com/en/free-pro-team@latest/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue
	issueCloseKeywords  = []string{"close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved"}
	issueReopenKeywords = []string{"reopen", "reopens", "reopened"}

	issueCloseKeywordsPattern  = lazyregexp.New(assembleKeywordsPattern(issueCloseKeywords))
	issueReopenKeywordsPattern = lazyregexp.New(assembleKeywordsPattern(issueReopenKeywords))
	issueReferencePattern      = lazyregexp.New(`(?i)(?:)(^| )\S*#\d+`)
)

func assembleKeywordsPattern(words []string) string {
	return fmt.Sprintf(`(?i)(?:%s) \S+`, strings.Join(words, "|"))
}

// updateCommitReferencesToIssues checks if issues are manipulated by commit message.
func updateCommitReferencesToIssues(doer *User, repo *Repository, commits []*PushCommit) error {
	trimRightNonDigits := func(c rune) bool {
		return !unicode.IsDigit(c)
	}

	// Commits are appended in the reverse order.
	for i := len(commits) - 1; i >= 0; i-- {
		c := commits[i]

		refMarked := make(map[int64]bool)
		for _, ref := range issueReferencePattern.FindAllString(c.Message, -1) {
			ref = strings.TrimSpace(ref)
			ref = strings.TrimRightFunc(ref, trimRightNonDigits)

			if ref == "" {
				continue
			}

			// Add repo name if missing
			if ref[0] == '#' {
				ref = fmt.Sprintf("%s%s", repo.FullName(), ref)
			} else if !strings.Contains(ref, "/") {
				// FIXME: We don't support User#ID syntax yet
				continue
			}

			issue, err := GetIssueByRef(ref)
			if err != nil {
				if IsErrIssueNotExist(err) {
					continue
				}
				return err
			}

			if refMarked[issue.ID] {
				continue
			}
			refMarked[issue.ID] = true

			msgLines := strings.Split(c.Message, "\n")
			shortMsg := msgLines[0]
			if len(msgLines) > 2 {
				shortMsg += "..."
			}
			message := fmt.Sprintf(`<a href="%s/commit/%s">%s</a>`, repo.Link(), c.Sha1, shortMsg)
			if err = CreateRefComment(doer, repo, issue, message, c.Sha1); err != nil {
				return err
			}
		}

		refMarked = make(map[int64]bool)
		// FIXME: Can merge this and the next for loop to a common function.
		for _, ref := range issueCloseKeywordsPattern.FindAllString(c.Message, -1) {
			ref = ref[strings.IndexByte(ref, byte(' '))+1:]
			ref = strings.TrimRightFunc(ref, trimRightNonDigits)

			if ref == "" {
				continue
			}

			// Add repo name if missing
			if ref[0] == '#' {
				ref = fmt.Sprintf("%s%s", repo.FullName(), ref)
			} else if !strings.Contains(ref, "/") {
				// FIXME: We don't support User#ID syntax yet
				continue
			}

			issue, err := GetIssueByRef(ref)
			if err != nil {
				if IsErrIssueNotExist(err) {
					continue
				}
				return err
			}

			if refMarked[issue.ID] {
				continue
			}
			refMarked[issue.ID] = true

			if issue.RepoID != repo.ID || issue.IsClosed {
				continue
			}

			if err = issue.ChangeStatus(doer, repo, true); err != nil {
				return err
			}
		}

		// It is conflict to have close and reopen at same time, so refsMarkd doesn't need to reinit here.
		for _, ref := range issueReopenKeywordsPattern.FindAllString(c.Message, -1) {
			ref = ref[strings.IndexByte(ref, byte(' '))+1:]
			ref = strings.TrimRightFunc(ref, trimRightNonDigits)

			if ref == "" {
				continue
			}

			// Add repo name if missing
			if ref[0] == '#' {
				ref = fmt.Sprintf("%s%s", repo.FullName(), ref)
			} else if !strings.Contains(ref, "/") {
				// We don't support User#ID syntax yet
				// return ErrNotImplemented
				continue
			}

			issue, err := GetIssueByRef(ref)
			if err != nil {
				if IsErrIssueNotExist(err) {
					continue
				}
				return err
			}

			if refMarked[issue.ID] {
				continue
			}
			refMarked[issue.ID] = true

			if issue.RepoID != repo.ID || !issue.IsClosed {
				continue
			}

			if err = issue.ChangeStatus(doer, repo, false); err != nil {
				return err
			}
		}
	}
	return nil
}

type CommitRepoOptions struct {
	Owner       *User
	Repo        *Repository
	PusherName  string
	RefFullName string
	OldCommitID string
	NewCommitID string
	Commits     *PushCommits
}

func (db *actions) CommitRepo(ctx context.Context, opts CommitRepoOptions) error {
	err := NewReposStore(db.DB).Touch(ctx, opts.Repo.ID)
	if err != nil {
		return errors.Wrap(err, "touch repository")
	}

	pusher, err := NewUsersStore(db.DB).GetByUsername(ctx, opts.PusherName)
	if err != nil {
		return errors.Wrapf(err, "get pusher [name: %s]", opts.PusherName)
	}

	isNewRef := opts.OldCommitID == git.EmptyID
	isDelRef := opts.NewCommitID == git.EmptyID

	// If not the first commit, set the compare URL.
	if !isNewRef && !isDelRef {
		opts.Commits.CompareURL = repoutil.CompareCommitsPath(opts.Owner.Name, opts.Repo.Name, opts.OldCommitID, opts.NewCommitID)
	}

	refName := git.RefShortName(opts.RefFullName)
	action := &Action{
		ActUserID:    pusher.ID,
		ActUserName:  pusher.Name,
		RepoID:       opts.Repo.ID,
		RepoUserName: opts.Owner.Name,
		RepoName:     opts.Repo.Name,
		RefName:      refName,
		IsPrivate:    opts.Repo.IsPrivate || opts.Repo.IsUnlisted,
	}

	apiRepo := opts.Repo.APIFormat(opts.Owner)
	apiPusher := pusher.APIFormat()
	if isDelRef {
		err = PrepareWebhooks(
			opts.Repo,
			HOOK_EVENT_DELETE,
			&api.DeletePayload{
				Ref:        refName,
				RefType:    "branch",
				PusherType: api.PUSHER_TYPE_USER,
				Repo:       apiRepo,
				Sender:     apiPusher,
			},
		)
		if err != nil {
			return errors.Wrap(err, "prepare webhooks for delete branch")
		}

		action.OpType = ActionDeleteBranch
		err = db.notifyWatchers(ctx, action)
		if err != nil {
			return errors.Wrap(err, "notify watchers")
		}

		// Delete branch doesn't have anything to push or compare
		return nil
	}

	// Only update issues via commits when internal issue tracker is enabled
	if opts.Repo.EnableIssues && !opts.Repo.EnableExternalTracker {
		if err = updateCommitReferencesToIssues(pusher, opts.Repo, opts.Commits.Commits); err != nil {
			log.Error("update commit references to issues: %v", err)
		}
	}

	if conf.UI.FeedMaxCommitNum > 0 && len(opts.Commits.Commits) > conf.UI.FeedMaxCommitNum {
		opts.Commits.Commits = opts.Commits.Commits[:conf.UI.FeedMaxCommitNum]
	}

	data, err := jsoniter.Marshal(opts.Commits)
	if err != nil {
		return errors.Wrap(err, "marshal JSON")
	}
	action.Content = string(data)

	var compareURL string
	if isNewRef {
		err = PrepareWebhooks(
			opts.Repo,
			HOOK_EVENT_CREATE,
			&api.CreatePayload{
				Ref:           refName,
				RefType:       "branch",
				DefaultBranch: opts.Repo.DefaultBranch,
				Repo:          apiRepo,
				Sender:        apiPusher,
			},
		)
		if err != nil {
			return errors.Wrap(err, "prepare webhooks for new branch")
		}

		action.OpType = ActionCreateBranch
		err = db.notifyWatchers(ctx, action)
		if err != nil {
			return errors.Wrap(err, "notify watchers")
		}
	} else {
		compareURL = conf.Server.ExternalURL + opts.Commits.CompareURL
	}

	commits, err := opts.Commits.APIFormat(ctx,
		NewUsersStore(db.DB),
		repoutil.RepositoryPath(opts.Owner.Name, opts.Repo.Name),
		repoutil.HTMLURL(opts.Owner.Name, opts.Repo.Name),
	)
	if err != nil {
		return errors.Wrap(err, "convert commits to API format")
	}

	err = PrepareWebhooks(
		opts.Repo,
		HOOK_EVENT_PUSH,
		&api.PushPayload{
			Ref:        opts.RefFullName,
			Before:     opts.OldCommitID,
			After:      opts.NewCommitID,
			CompareURL: compareURL,
			Commits:    commits,
			Repo:       apiRepo,
			Pusher:     apiPusher,
			Sender:     apiPusher,
		},
	)
	if err != nil {
		return errors.Wrap(err, "prepare webhooks for new commit")
	}

	action.OpType = ActionCommitRepo
	err = db.notifyWatchers(ctx, action)
	if err != nil {
		return errors.Wrap(err, "notify watchers")
	}
	return nil
}

type PushTagOptions struct {
	Owner       *User
	Repo        *Repository
	PusherName  string
	RefFullName string
	NewCommitID string
}

func (db *actions) PushTag(ctx context.Context, opts PushTagOptions) error {
	err := NewReposStore(db.DB).Touch(ctx, opts.Repo.ID)
	if err != nil {
		return errors.Wrap(err, "touch repository")
	}

	pusher, err := NewUsersStore(db.DB).GetByUsername(ctx, opts.PusherName)
	if err != nil {
		return errors.Wrapf(err, "get pusher [name: %s]", opts.PusherName)
	}

	refName := git.RefShortName(opts.RefFullName)
	action := &Action{
		ActUserID:    pusher.ID,
		ActUserName:  pusher.Name,
		RepoID:       opts.Repo.ID,
		RepoUserName: opts.Owner.Name,
		RepoName:     opts.Repo.Name,
		RefName:      refName,
		IsPrivate:    opts.Repo.IsPrivate || opts.Repo.IsUnlisted,
	}

	apiRepo := opts.Repo.APIFormat(opts.Owner)
	apiPusher := pusher.APIFormat()
	if opts.NewCommitID == git.EmptyID {
		err = PrepareWebhooks(
			opts.Repo,
			HOOK_EVENT_DELETE,
			&api.DeletePayload{
				Ref:        refName,
				RefType:    "tag",
				PusherType: api.PUSHER_TYPE_USER,
				Repo:       apiRepo,
				Sender:     apiPusher,
			},
		)
		if err != nil {
			return errors.Wrap(err, "prepare webhooks for delete tag")
		}

		action.OpType = ActionDeleteTag
		err = db.notifyWatchers(ctx, action)
		if err != nil {
			return errors.Wrap(err, "notify watchers")
		}
		return nil
	}

	err = PrepareWebhooks(
		opts.Repo,
		HOOK_EVENT_CREATE,
		&api.CreatePayload{
			Ref:           refName,
			RefType:       "tag",
			Sha:           opts.NewCommitID,
			DefaultBranch: opts.Repo.DefaultBranch,
			Repo:          apiRepo,
			Sender:        apiPusher,
		},
	)
	if err != nil {
		return errors.Wrapf(err, "prepare webhooks for new tag")
	}

	action.OpType = ActionPushTag
	err = db.notifyWatchers(ctx, action)
	if err != nil {
		return errors.Wrap(err, "notify watchers")
	}
	return nil
}

// ActionType is the type of an action.
type ActionType int

// ⚠️ WARNING: Only append to the end of list to maintain backward compatibility.
const (
	ActionCreateRepo        ActionType = iota + 1 // 1
	ActionRenameRepo                              // 2
	ActionStarRepo                                // 3
	ActionWatchRepo                               // 4
	ActionCommitRepo                              // 5
	ActionCreateIssue                             // 6
	ActionCreatePullRequest                       // 7
	ActionTransferRepo                            // 8
	ActionPushTag                                 // 9
	ActionCommentIssue                            // 10
	ActionMergePullRequest                        // 11
	ActionCloseIssue                              // 12
	ActionReopenIssue                             // 13
	ActionClosePullRequest                        // 14
	ActionReopenPullRequest                       // 15
	ActionCreateBranch                            // 16
	ActionDeleteBranch                            // 17
	ActionDeleteTag                               // 18
	ActionForkRepo                                // 19
	ActionMirrorSyncPush                          // 20
	ActionMirrorSyncCreate                        // 21
	ActionMirrorSyncDelete                        // 22
)

// Action is a user operation to a repository. It implements template.Actioner
// interface to be able to use it in template rendering.
type Action struct {
	ID           int64 `gorm:"primaryKey"`
	UserID       int64 `gorm:"index"` // Receiver user ID
	OpType       ActionType
	ActUserID    int64  // Doer user ID
	ActUserName  string // Doer user name
	ActAvatar    string `xorm:"-" gorm:"-" json:"-"`
	RepoID       int64  `xorm:"INDEX" gorm:"index"`
	RepoUserName string
	RepoName     string
	RefName      string
	IsPrivate    bool   `xorm:"NOT NULL DEFAULT false" gorm:"not null;default:FALSE"`
	Content      string `xorm:"TEXT"`

	Created     time.Time `xorm:"-" gorm:"-" json:"-"`
	CreatedUnix int64
}

// BeforeCreate implements the GORM create hook.
func (a *Action) BeforeCreate(tx *gorm.DB) error {
	if a.CreatedUnix <= 0 {
		a.CreatedUnix = tx.NowFunc().Unix()
	}
	return nil
}

// AfterFind implements the GORM query hook.
func (a *Action) AfterFind(_ *gorm.DB) error {
	a.Created = time.Unix(a.CreatedUnix, 0).Local()
	return nil
}

func (a *Action) GetOpType() int {
	return int(a.OpType)
}

func (a *Action) GetActUserName() string {
	return a.ActUserName
}

func (a *Action) ShortActUserName() string {
	return strutil.Ellipsis(a.ActUserName, 20)
}

func (a *Action) GetRepoUserName() string {
	return a.RepoUserName
}

func (a *Action) ShortRepoUserName() string {
	return strutil.Ellipsis(a.RepoUserName, 20)
}

func (a *Action) GetRepoName() string {
	return a.RepoName
}

func (a *Action) ShortRepoName() string {
	return strutil.Ellipsis(a.RepoName, 33)
}

func (a *Action) GetRepoPath() string {
	return path.Join(a.RepoUserName, a.RepoName)
}

func (a *Action) ShortRepoPath() string {
	return path.Join(a.ShortRepoUserName(), a.ShortRepoName())
}

func (a *Action) GetRepoLink() string {
	if conf.Server.Subpath != "" {
		return path.Join(conf.Server.Subpath, a.GetRepoPath())
	}
	return "/" + a.GetRepoPath()
}

func (a *Action) GetBranch() string {
	return a.RefName
}

func (a *Action) GetContent() string {
	return a.Content
}

func (a *Action) GetCreate() time.Time {
	return a.Created
}

func (a *Action) GetIssueInfos() []string {
	return strings.SplitN(a.Content, "|", 2)
}

func (a *Action) GetIssueTitle() string {
	index, _ := strconv.ParseInt(a.GetIssueInfos()[0], 10, 64)
	issue, err := GetIssueByIndex(a.RepoID, index)
	if err != nil {
		log.Error("Failed to get issue title [repo_id: %d, index: %d]: %v", a.RepoID, index, err)
		return "error getting issue"
	}
	return issue.Title
}

func (a *Action) GetIssueContent() string {
	index, _ := strconv.ParseInt(a.GetIssueInfos()[0], 10, 64)
	issue, err := GetIssueByIndex(a.RepoID, index)
	if err != nil {
		log.Error("Failed to get issue content [repo_id: %d, index: %d]: %v", a.RepoID, index, err)
		return "error getting issue"
	}
	return issue.Content
}

// PushCommit contains information of a pushed commit.
type PushCommit struct {
	Sha1           string
	Message        string
	AuthorEmail    string
	AuthorName     string
	CommitterEmail string
	CommitterName  string
	Timestamp      time.Time
}

// PushCommits is a list of pushed commits.
type PushCommits struct {
	Len        int
	Commits    []*PushCommit
	CompareURL string

	avatars map[string]string
}

// NewPushCommits returns a new PushCommits.
func NewPushCommits() *PushCommits {
	return &PushCommits{
		avatars: make(map[string]string),
	}
}

func (pcs *PushCommits) APIFormat(ctx context.Context, usersStore UsersStore, repoPath, repoURL string) ([]*api.PayloadCommit, error) {
	// NOTE: We cache query results in case there are many commits in a single push.
	usernameByEmail := make(map[string]string)
	getUsernameByEmail := func(email string) (string, error) {
		username, ok := usernameByEmail[email]
		if ok {
			return username, nil
		}

		user, err := usersStore.GetByEmail(ctx, email)
		if err != nil {
			if IsErrUserNotExist(err) {
				usernameByEmail[email] = ""
				return "", nil
			}
			return "", err
		}

		usernameByEmail[email] = user.Name
		return user.Name, nil
	}

	commits := make([]*api.PayloadCommit, len(pcs.Commits))
	for i, commit := range pcs.Commits {
		authorUsername, err := getUsernameByEmail(commit.AuthorEmail)
		if err != nil {
			return nil, errors.Wrap(err, "get author username")
		}

		committerUsername, err := getUsernameByEmail(commit.CommitterEmail)
		if err != nil {
			return nil, errors.Wrap(err, "get committer username")
		}

		nameStatus := &git.NameStatus{}
		if !testutil.InTest {
			nameStatus, err = git.ShowNameStatus(repoPath, commit.Sha1)
			if err != nil {
				return nil, errors.Wrapf(err, "show name status [commit_sha1: %s]", commit.Sha1)
			}
		}

		commits[i] = &api.PayloadCommit{
			ID:      commit.Sha1,
			Message: commit.Message,
			URL:     fmt.Sprintf("%s/commit/%s", repoURL, commit.Sha1),
			Author: &api.PayloadUser{
				Name:     commit.AuthorName,
				Email:    commit.AuthorEmail,
				UserName: authorUsername,
			},
			Committer: &api.PayloadUser{
				Name:     commit.CommitterName,
				Email:    commit.CommitterEmail,
				UserName: committerUsername,
			},
			Added:     nameStatus.Added,
			Removed:   nameStatus.Removed,
			Modified:  nameStatus.Modified,
			Timestamp: commit.Timestamp,
		}
	}
	return commits, nil
}

// AvatarLink tries to match user in database with email in order to show custom
// avatars, and falls back to general avatar link.
//
// FIXME: This method does not belong to PushCommits, should be a pure template
// function.
func (pcs *PushCommits) AvatarLink(email string) string {
	_, ok := pcs.avatars[email]
	if !ok {
		u, err := Users.GetByEmail(context.Background(), email)
		if err != nil {
			pcs.avatars[email] = tool.AvatarLink(email)
			if !IsErrUserNotExist(err) {
				log.Error("Failed to get user [email: %s]: %v", email, err)
			}
		} else {
			pcs.avatars[email] = u.AvatarURLPath()
		}
	}

	return pcs.avatars[email]
}
