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

package actions

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

	runnerv1 "gitea.dev/actions-proto-go/runner/v1"
	"gitea.dev/models/db"
	repo_model "gitea.dev/models/repo"
	"gitea.dev/models/shared/types"
	user_model "gitea.dev/models/user"
	"gitea.dev/modules/container"
	"gitea.dev/modules/optional"
	"gitea.dev/modules/setting"
	"gitea.dev/modules/timeutil"
	"gitea.dev/modules/translation"
	"gitea.dev/modules/util"

	"xorm.io/builder"
)

// ActionRunner represents runner machines
//
// It can be:
//  1. global runner, OwnerID is 0 and RepoID is 0
//  2. org/user level runner, OwnerID is org/user ID and RepoID is 0
//  3. repo level runner, OwnerID is 0 and RepoID is repo ID
//
// Please note that it's not acceptable to have both OwnerID and RepoID to be non-zero,
// or it will be complicated to find runners belonging to a specific owner.
// For example, conditions like `OwnerID = 1` will also return runner {OwnerID: 1, RepoID: 1},
// but it's a repo level runner, not an org/user level runner.
// To avoid this, make it clear with {OwnerID: 0, RepoID: 1} for repo level runners.
type ActionRunner struct {
	ID          int64
	UUID        string                 `xorm:"CHAR(36) UNIQUE"`
	Name        string                 `xorm:"VARCHAR(255)"`
	Version     string                 `xorm:"VARCHAR(64)"`
	OwnerID     int64                  `xorm:"index"`
	Owner       *user_model.User       `xorm:"-"`
	RepoID      int64                  `xorm:"index"`
	Repo        *repo_model.Repository `xorm:"-"`
	Description string                 `xorm:"TEXT"`
	Base        int                    // 0 native 1 docker 2 virtual machine
	RepoRange   string                 // glob match which repositories could use this runner

	Token     string `xorm:"-"`
	TokenHash string `xorm:"UNIQUE"` // sha256 of token
	TokenSalt string
	// TokenLastEight string `xorm:"token_last_eight"` // it's unnecessary because we don't find runners by token

	LastOnline timeutil.TimeStamp `xorm:"index"`
	LastActive timeutil.TimeStamp `xorm:"index"`

	// Store labels defined in state file (default: .runner file) of `act_runner`
	AgentLabels []string `xorm:"TEXT"`
	// Store if this is a runner that only ever get one single job assigned
	Ephemeral bool `xorm:"ephemeral NOT NULL DEFAULT false"`
	// Store if this runner is disabled and should not pick up new jobs
	IsDisabled bool `xorm:"is_disabled NOT NULL DEFAULT false"`
	// Store if this runner supports the StatusCancelling flow
	HasCancellingSupport bool `xorm:"has_cancelling_support NOT NULL DEFAULT false"`

	Created timeutil.TimeStamp `xorm:"created"`
	Updated timeutil.TimeStamp `xorm:"updated"`
	Deleted timeutil.TimeStamp `xorm:"deleted"`
}

const (
	RunnerOfflineTime = time.Minute
	RunnerIdleTime    = 10 * time.Second
)

// BelongsToOwnerName before calling, should guarantee that all attributes are loaded
func (r *ActionRunner) BelongsToOwnerName() string {
	if r.RepoID != 0 {
		return r.Repo.FullName()
	}
	if r.OwnerID != 0 {
		return r.Owner.Name
	}
	return ""
}

func (r *ActionRunner) BelongsToOwnerType() types.OwnerType {
	if r.RepoID != 0 {
		return types.OwnerTypeRepository
	}
	if r.OwnerID != 0 {
		switch r.Owner.Type {
		case user_model.UserTypeOrganization:
			return types.OwnerTypeOrganization
		case user_model.UserTypeIndividual:
			return types.OwnerTypeIndividual
		}
	}
	return types.OwnerTypeSystemGlobal
}

// if the logic here changed, you should also modify FindRunnerOptions.ToCond
func (r *ActionRunner) Status() runnerv1.RunnerStatus {
	if time.Since(r.LastOnline.AsTime()) > RunnerOfflineTime {
		return runnerv1.RunnerStatus_RUNNER_STATUS_OFFLINE
	}
	if time.Since(r.LastActive.AsTime()) > RunnerIdleTime {
		return runnerv1.RunnerStatus_RUNNER_STATUS_IDLE
	}
	return runnerv1.RunnerStatus_RUNNER_STATUS_ACTIVE
}

func (r *ActionRunner) StatusName() string {
	return strings.ToLower(strings.TrimPrefix(r.Status().String(), "RUNNER_STATUS_"))
}

func (r *ActionRunner) StatusLocaleName(lang translation.Locale) string {
	return lang.TrString("actions.runners.status." + r.StatusName())
}

func (r *ActionRunner) IsOnline() bool {
	status := r.Status()
	if status == runnerv1.RunnerStatus_RUNNER_STATUS_IDLE || status == runnerv1.RunnerStatus_RUNNER_STATUS_ACTIVE {
		return true
	}
	return false
}

// EditableInContext checks if the runner is editable by the "context" owner/repo
// ownerID == 0 and repoID == 0 means "admin" context, any runner including global runners could be edited
// ownerID == 0 and repoID != 0 means "repo" context, any runner belonging to the given repo could be edited
// ownerID != 0 and repoID == 0 means "owner(org/user)" context, any runner belonging to the given user/org could be edited
// ownerID != 0 and repoID != 0 means "owner" OR "repo" context, legacy behavior, but we should forbid using it
func (r *ActionRunner) EditableInContext(ownerID, repoID int64) bool {
	if ownerID != 0 && repoID != 0 {
		setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
	}
	if ownerID == 0 && repoID == 0 {
		return true
	}
	if ownerID > 0 && r.OwnerID == ownerID {
		return true
	}
	return repoID > 0 && r.RepoID == repoID
}

// LoadAttributes loads the attributes of the runner
func (r *ActionRunner) LoadAttributes(ctx context.Context) error {
	if r.OwnerID > 0 {
		var user user_model.User
		has, err := db.GetEngine(ctx).ID(r.OwnerID).Get(&user)
		if err != nil {
			return err
		}
		if has {
			r.Owner = &user
		}
	}
	if r.RepoID > 0 {
		var repo repo_model.Repository
		has, err := db.GetEngine(ctx).ID(r.RepoID).Get(&repo)
		if err != nil {
			return err
		}
		if has {
			r.Repo = &repo
		}
	}
	return nil
}

func (r *ActionRunner) GenerateAndFillToken() {
	r.Token, r.TokenSalt, r.TokenHash, _ = generateSaltedToken()
}

// CanMatchLabels checks whether the runner's labels can match a job's "runs-on"
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idruns-on
func (r *ActionRunner) CanMatchLabels(jobRunsOn []string) bool {
	runnerLabelSet := container.SetOf(r.AgentLabels...)
	return runnerLabelSet.Contains(jobRunsOn...) // match all labels
}

func init() {
	db.RegisterModel(&ActionRunner{})
}

// FindRunnerOptions
// ownerID == 0 and repoID == 0 means any runner including global runners
// repoID != 0 and WithAvailable == false means any runner for the given repo
// repoID != 0 and WithAvailable == true means any runner for the given repo, parent user/org, and global runners
// ownerID != 0 and repoID == 0 and WithAvailable == false means any runner for the given user/org
// ownerID != 0 and repoID == 0 and WithAvailable == true means any runner for the given user/org and global runners
type FindRunnerOptions struct {
	db.ListOptions
	IDs           []int64
	RepoID        int64
	OwnerID       int64 // it will be ignored if RepoID is set
	Sort          string
	Filter        string
	IsOnline      optional.Option[bool]
	IsDisabled    optional.Option[bool]
	WithAvailable bool // not only runners belong to, but also runners can be used
}

func (opts FindRunnerOptions) ToConds() builder.Cond {
	cond := builder.NewCond()

	if len(opts.IDs) > 0 {
		if len(opts.IDs) == 1 {
			cond = cond.And(builder.Eq{"id": opts.IDs[0]})
		} else {
			cond = cond.And(builder.In("id", opts.IDs))
		}
	}

	if opts.RepoID > 0 {
		c := builder.NewCond().And(builder.Eq{"repo_id": opts.RepoID})
		if opts.WithAvailable {
			c = c.Or(builder.Eq{"owner_id": builder.Select("owner_id").From("repository").Where(builder.Eq{"id": opts.RepoID})})
			c = c.Or(builder.Eq{"repo_id": 0, "owner_id": 0})
		}
		cond = cond.And(c)
	} else if opts.OwnerID > 0 { // OwnerID is ignored if RepoID is set
		c := builder.NewCond().And(builder.Eq{"owner_id": opts.OwnerID})
		if opts.WithAvailable {
			c = c.Or(builder.Eq{"repo_id": 0, "owner_id": 0})
		}
		cond = cond.And(c)
	}

	if opts.Filter != "" {
		cond = cond.And(builder.Like{"name", opts.Filter})
	}

	if opts.IsOnline.Has() {
		if opts.IsOnline.Value() {
			cond = cond.And(builder.Gt{"last_online": time.Now().Add(-RunnerOfflineTime).Unix()})
		} else {
			cond = cond.And(builder.Lte{"last_online": time.Now().Add(-RunnerOfflineTime).Unix()})
		}
	}

	if opts.IsDisabled.Has() {
		cond = cond.And(builder.Eq{"is_disabled": opts.IsDisabled.Value()})
	}
	return cond
}

func (opts FindRunnerOptions) ToOrders() string {
	switch opts.Sort {
	case "online":
		return "last_online DESC"
	case "offline":
		return "last_online ASC"
	case "alphabetically":
		return "name ASC"
	case "reversealphabetically":
		return "name DESC"
	case "newest":
		return "id DESC"
	case "oldest":
		return "id ASC"
	}
	return "last_online DESC"
}

// GetRunnerByUUID returns a runner via uuid
func GetRunnerByUUID(ctx context.Context, uuid string) (*ActionRunner, error) {
	var runner ActionRunner
	has, err := db.GetEngine(ctx).Where("uuid=?", uuid).Get(&runner)
	if err != nil {
		return nil, err
	} else if !has {
		return nil, fmt.Errorf("runner with uuid %s: %w", uuid, util.ErrNotExist)
	}
	return &runner, nil
}

// GetRunnerByID returns a runner via id
func GetRunnerByID(ctx context.Context, id int64) (*ActionRunner, error) {
	var runner ActionRunner
	has, err := db.GetEngine(ctx).Where("id=?", id).Get(&runner)
	if err != nil {
		return nil, err
	} else if !has {
		return nil, fmt.Errorf("runner with id %d: %w", id, util.ErrNotExist)
	}
	return &runner, nil
}

// UpdateRunner updates runner's information.
func UpdateRunner(ctx context.Context, r *ActionRunner, cols ...string) error {
	e := db.GetEngine(ctx)
	r.Name = util.EllipsisDisplayString(r.Name, 255)
	var err error
	if len(cols) == 0 {
		_, err = e.ID(r.ID).AllCols().Update(r)
	} else {
		_, err = e.ID(r.ID).Cols(cols...).Update(r)
	}
	return err
}

func SetRunnerDisabled(ctx context.Context, runner *ActionRunner, isDisabled bool) error {
	if runner.IsDisabled == isDisabled {
		return nil
	}

	return db.WithTx(ctx, func(ctx context.Context) error {
		runner.IsDisabled = isDisabled
		if err := UpdateRunner(ctx, runner, "is_disabled"); err != nil {
			return err
		}
		return IncreaseTaskVersion(ctx, runner.OwnerID, runner.RepoID)
	})
}

// DeleteRunner deletes a runner by given ID.
func DeleteRunner(ctx context.Context, id int64) error {
	if _, err := GetRunnerByID(ctx, id); err != nil {
		return err
	}

	_, err := db.DeleteByID[ActionRunner](ctx, id)
	return err
}

// DeleteEphemeralRunner deletes a ephemeral runner by given ID.
func DeleteEphemeralRunner(ctx context.Context, id int64) error {
	runner, err := GetRunnerByID(ctx, id)
	if err != nil {
		if errors.Is(err, util.ErrNotExist) {
			return nil
		}
		return err
	}
	if !runner.Ephemeral {
		return nil
	}

	_, err = db.DeleteByID[ActionRunner](ctx, id)
	return err
}

// CreateRunner creates new runner.
func CreateRunner(ctx context.Context, t *ActionRunner) error {
	if t.OwnerID != 0 && t.RepoID != 0 {
		// It's trying to create a runner that belongs to a repository, but OwnerID has been set accidentally.
		// Remove OwnerID to avoid confusion; it's not worth returning an error here.
		t.OwnerID = 0
	}
	t.Name = util.EllipsisDisplayString(t.Name, 255)
	return db.Insert(ctx, t)
}

func CountRunnersWithoutBelongingOwner(ctx context.Context) (int64, error) {
	// Only affect action runners were a owner ID is set, as actions runners
	// could also be created on a repository.
	return db.GetEngine(ctx).Table("action_runner").
		Join("LEFT", "`user`", "`action_runner`.owner_id = `user`.id").
		Where("`action_runner`.owner_id != ?", 0).
		And(builder.IsNull{"`user`.id"}).
		Count(new(ActionRunner))
}

func FixRunnersWithoutBelongingOwner(ctx context.Context) (int64, error) {
	subQuery := builder.Select("`action_runner`.id").
		From("`action_runner`").
		Join("LEFT", "`user`", "`action_runner`.owner_id = `user`.id").
		Where(builder.Neq{"`action_runner`.owner_id": 0}).
		And(builder.IsNull{"`user`.id"})
	b := builder.Delete(builder.In("id", subQuery)).From("`action_runner`")
	res, err := db.GetEngine(ctx).Exec(b)
	if err != nil {
		return 0, err
	}
	return res.RowsAffected()
}

func CountRunnersWithoutBelongingRepo(ctx context.Context) (int64, error) {
	return db.GetEngine(ctx).Table("action_runner").
		Join("LEFT", "`repository`", "`action_runner`.repo_id = `repository`.id").
		Where("`action_runner`.repo_id != ?", 0).
		And(builder.IsNull{"`repository`.id"}).
		Count(new(ActionRunner))
}

func FixRunnersWithoutBelongingRepo(ctx context.Context) (int64, error) {
	subQuery := builder.Select("`action_runner`.id").
		From("`action_runner`").
		Join("LEFT", "`repository`", "`action_runner`.repo_id = `repository`.id").
		Where(builder.Neq{"`action_runner`.repo_id": 0}).
		And(builder.IsNull{"`repository`.id"})
	b := builder.Delete(builder.In("id", subQuery)).From("`action_runner`")
	res, err := db.GetEngine(ctx).Exec(b)
	if err != nil {
		return 0, err
	}
	return res.RowsAffected()
}

func CountWrongRepoLevelRunners(ctx context.Context) (int64, error) {
	var result int64
	_, err := db.GetEngine(ctx).SQL("SELECT count(`id`) FROM `action_runner` WHERE `repo_id` > 0 AND `owner_id` > 0").Get(&result)
	return result, err
}

func UpdateWrongRepoLevelRunners(ctx context.Context) (int64, error) {
	result, err := db.GetEngine(ctx).Exec("UPDATE `action_runner` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0")
	if err != nil {
		return 0, err
	}
	return result.RowsAffected()
}
