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

package actions

import (
	"archive/zip"
	"compress/gzip"
	"context"
	"errors"
	"fmt"
	"html/template"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"time"

	actions_model "code.gitea.io/gitea/models/actions"
	"code.gitea.io/gitea/models/db"
	git_model "code.gitea.io/gitea/models/git"
	repo_model "code.gitea.io/gitea/models/repo"
	"code.gitea.io/gitea/models/unit"
	"code.gitea.io/gitea/modules/actions"
	"code.gitea.io/gitea/modules/base"
	"code.gitea.io/gitea/modules/git"
	"code.gitea.io/gitea/modules/httplib"
	"code.gitea.io/gitea/modules/log"
	"code.gitea.io/gitea/modules/storage"
	"code.gitea.io/gitea/modules/templates"
	"code.gitea.io/gitea/modules/translation"
	"code.gitea.io/gitea/modules/util"
	"code.gitea.io/gitea/modules/web"
	"code.gitea.io/gitea/routers/common"
	actions_service "code.gitea.io/gitea/services/actions"
	context_module "code.gitea.io/gitea/services/context"
	notify_service "code.gitea.io/gitea/services/notify"

	"github.com/nektos/act/pkg/model"
)

func findCurrentJobByPathParam(ctx *context_module.Context, jobs []*actions_model.ActionRunJob) (job *actions_model.ActionRunJob, hasPathParam bool) {
	selectedJobID := ctx.PathParamInt64("job")
	if selectedJobID <= 0 {
		return nil, false
	}
	for _, job = range jobs {
		if job.ID == selectedJobID {
			return job, true
		}
	}
	return nil, true
}

func getCurrentRunByPathParam(ctx *context_module.Context) (run *actions_model.ActionRun) {
	var err error
	// if run param is "latest", get the latest run id
	if ctx.PathParam("run") == "latest" {
		run, err = actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID)
	} else {
		run, err = actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("run"))
	}
	if errors.Is(err, util.ErrNotExist) {
		ctx.NotFound(nil)
	} else if err != nil {
		ctx.ServerError("GetRun:"+ctx.PathParam("run"), err)
	}
	return run
}

// resolveCurrentRunForView resolves GET Actions page URLs and supports both ID-based and legacy index-based forms.
//
// By default, run summary pages (/actions/runs/{run}) use a best-effort ID-first fallback,
// and job pages (/actions/runs/{run}/jobs/{job}) try to confirm an ID-based URL first and prefer the ID-based interpretation when both are valid.
//
// `by_id=1` param explicitly forces the ID-based path, and `by_index=1` explicitly forces the legacy index-based path.
// If both are present, `by_id` takes precedence.
func resolveCurrentRunForView(ctx *context_module.Context) *actions_model.ActionRun {
	// `by_id` explicitly requests ID-based resolution, so the request skips the legacy index-based disambiguation logic and resolves the run by ID directly.
	// It takes precedence over `by_index` when both query parameters are present.
	if ctx.PathParam("run") == "latest" || ctx.FormBool("by_id") {
		return getCurrentRunByPathParam(ctx)
	}

	runNum := ctx.PathParamInt64("run")
	if runNum <= 0 {
		ctx.NotFound(nil)
		return nil
	}

	byIndex := ctx.FormBool("by_index")

	if ctx.PathParam("job") == "" {
		// The URL does not contain a {job} path parameter, so it cannot use the
		// job-specific rules to disambiguate ID-based URLs from legacy index-based URLs.
		// Because of that, this path is handled with a best-effort ID-first fallback by default.
		//
		// When the same repository contains:
		//  - a run whose ID matches runNum, and
		//  - a different run whose repo-scope index also matches runNum
		// this path prefers the ID match and may show a different run than the old legacy URL originally intended,
		// unless `by_index=1` explicitly forces the legacy index-based interpretation.

		if !byIndex {
			runByID, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runNum)
			if err == nil {
				return runByID
			}
			if !errors.Is(err, util.ErrNotExist) {
				ctx.ServerError("GetRun:"+ctx.PathParam("run"), err)
				return nil
			}
		}

		runByIndex, err := actions_model.GetRunByRepoAndIndex(ctx, ctx.Repo.Repository.ID, runNum)
		if err == nil {
			ctx.Redirect(fmt.Sprintf("%s/actions/runs/%d", ctx.Repo.RepoLink, runByIndex.ID), http.StatusFound)
			return nil
		}
		if !errors.Is(err, util.ErrNotExist) {
			ctx.ServerError("GetRunByRepoAndIndex", err)
			return nil
		}
		ctx.NotFound(nil)
		return nil
	}

	jobNum := ctx.PathParamInt64("job")
	if jobNum < 0 {
		ctx.NotFound(nil)
		return nil
	}

	// A job index should not be larger than MaxJobNumPerRun, so larger values can skip the legacy index-based path and be treated as job IDs directly.
	if !byIndex && jobNum >= actions_model.MaxJobNumPerRun {
		return getCurrentRunByPathParam(ctx)
	}

	var runByID, runByIndex *actions_model.ActionRun
	var targetJobByIndex *actions_model.ActionRunJob

	if !byIndex {
		// Probe the repo-scoped job ID first and only accept it when the job exists and belongs to the same runNum.
		job, err := actions_model.GetRunJobByRepoAndID(ctx, ctx.Repo.Repository.ID, jobNum)
		if err != nil && !errors.Is(err, util.ErrNotExist) {
			ctx.ServerError("GetRunJobByRepoAndID", err)
			return nil
		}
		if job != nil {
			if err := job.LoadRun(ctx); err != nil {
				ctx.ServerError("LoadRun", err)
				return nil
			}
			if job.Run.ID == runNum {
				runByID = job.Run
			}
		}
	}

	// Try to resolve the request as a legacy run-index/job-index URL.
	{
		run, err := actions_model.GetRunByRepoAndIndex(ctx, ctx.Repo.Repository.ID, runNum)
		if err != nil && !errors.Is(err, util.ErrNotExist) {
			ctx.ServerError("GetRunByRepoAndIndex", err)
			return nil
		}
		if run != nil {
			jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
			if err != nil {
				ctx.ServerError("GetRunJobsByRunID", err)
				return nil
			}
			if jobNum < int64(len(jobs)) {
				runByIndex = run
				targetJobByIndex = jobs[jobNum]
			}
		}
	}

	if runByID == nil && runByIndex == nil {
		ctx.NotFound(nil)
		return nil
	}

	if runByID != nil && runByIndex == nil {
		return runByID
	}

	if runByID == nil && runByIndex != nil {
		ctx.Redirect(fmt.Sprintf("%s/actions/runs/%d/jobs/%d", ctx.Repo.RepoLink, runByIndex.ID, targetJobByIndex.ID), http.StatusFound)
		return nil
	}

	// Reaching this point means both ID-based and legacy index-based interpretations are valid. Prefer the ID-based interpretation by default.
	// Use `by_index=1` query parameter to access the legacy index-based interpretation when necessary.
	return runByID
}

func View(ctx *context_module.Context) {
	ctx.Data["PageIsActions"] = true
	run := resolveCurrentRunForView(ctx)
	if ctx.Written() {
		return
	}
	ctx.Data["RunID"] = run.ID
	ctx.Data["JobID"] = ctx.PathParamInt64("job") // it can be 0 when no job (e.g.: run summary view)
	ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions"

	ctx.HTML(http.StatusOK, tplViewActions)
}

func ViewWorkflowFile(ctx *context_module.Context) {
	run := getCurrentRunByPathParam(ctx)
	if ctx.Written() {
		return
	}

	commit, err := ctx.Repo.GitRepo.GetCommit(run.CommitSHA)
	if err != nil {
		ctx.NotFoundOrServerError("GetCommit", func(err error) bool {
			return errors.Is(err, util.ErrNotExist)
		}, err)
		return
	}
	rpath, entries, err := actions.ListWorkflows(commit)
	if err != nil {
		ctx.ServerError("ListWorkflows", err)
		return
	}
	for _, entry := range entries {
		if entry.Name() == run.WorkflowID {
			ctx.Redirect(fmt.Sprintf("%s/src/commit/%s/%s/%s", ctx.Repo.RepoLink, url.PathEscape(run.CommitSHA), util.PathEscapeSegments(rpath), util.PathEscapeSegments(run.WorkflowID)))
			return
		}
	}
	ctx.NotFound(nil)
}

type LogCursor struct {
	Step     int   `json:"step"`
	Cursor   int64 `json:"cursor"`
	Expanded bool  `json:"expanded"`
}

type ViewRequest struct {
	LogCursors []LogCursor `json:"logCursors"`
}

type ArtifactsViewItem struct {
	Name   string `json:"name"`
	Size   int64  `json:"size"`
	Status string `json:"status"`
}

type ViewResponse struct {
	Artifacts []*ArtifactsViewItem `json:"artifacts"`

	State struct {
		Run struct {
			RepoID            int64         `json:"repoId"`
			Link              string        `json:"link"`
			Title             string        `json:"title"`
			TitleHTML         template.HTML `json:"titleHTML"`
			Status            string        `json:"status"`
			CanCancel         bool          `json:"canCancel"`
			CanApprove        bool          `json:"canApprove"` // the run needs an approval and the doer has permission to approve
			CanRerun          bool          `json:"canRerun"`
			CanRerunFailed    bool          `json:"canRerunFailed"`
			CanDeleteArtifact bool          `json:"canDeleteArtifact"`
			Done              bool          `json:"done"`
			WorkflowID        string        `json:"workflowID"`
			WorkflowLink      string        `json:"workflowLink"`
			IsSchedule        bool          `json:"isSchedule"`
			Jobs              []*ViewJob    `json:"jobs"`
			Commit            ViewCommit    `json:"commit"`
			// Summary view: run duration and trigger time/event
			Duration     string `json:"duration"`
			TriggeredAt  int64  `json:"triggeredAt"`  // unix seconds for relative time
			TriggerEvent string `json:"triggerEvent"` // e.g. pull_request, push, schedule
		} `json:"run"`
		CurrentJob struct {
			Title  string         `json:"title"`
			Detail string         `json:"detail"`
			Steps  []*ViewJobStep `json:"steps"`
		} `json:"currentJob"`
	} `json:"state"`
	Logs struct {
		StepsLog []*ViewStepLog `json:"stepsLog"`
	} `json:"logs"`
}

type ViewJob struct {
	ID       int64    `json:"id"`
	JobID    string   `json:"jobId,omitempty"`
	Name     string   `json:"name"`
	Status   string   `json:"status"`
	CanRerun bool     `json:"canRerun"`
	Duration string   `json:"duration"`
	Needs    []string `json:"needs,omitempty"`
}

type ViewCommit struct {
	ShortSha string     `json:"shortSHA"`
	Link     string     `json:"link"`
	Pusher   ViewUser   `json:"pusher"`
	Branch   ViewBranch `json:"branch"`
}

type ViewUser struct {
	DisplayName string `json:"displayName"`
	Link        string `json:"link"`
}

type ViewBranch struct {
	Name      string `json:"name"`
	Link      string `json:"link"`
	IsDeleted bool   `json:"isDeleted"`
}

type ViewJobStep struct {
	Summary  string `json:"summary"`
	Duration string `json:"duration"`
	Status   string `json:"status"`
}

type ViewStepLog struct {
	Step    int                `json:"step"`
	Cursor  int64              `json:"cursor"`
	Lines   []*ViewStepLogLine `json:"lines"`
	Started int64              `json:"started"`
}

type ViewStepLogLine struct {
	Index     int64   `json:"index"`
	Message   string  `json:"message"`
	Timestamp float64 `json:"timestamp"`
}

func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifactsViewItems []*ArtifactsViewItem, err error) {
	artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, repoID, runID)
	if err != nil {
		return nil, err
	}
	for _, art := range artifacts {
		artifactsViewItems = append(artifactsViewItems, &ArtifactsViewItem{
			Name:   art.ArtifactName,
			Size:   art.FileSize,
			Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
		})
	}
	return artifactsViewItems, nil
}

func ViewPost(ctx *context_module.Context) {
	run, jobs := getCurrentRunJobsByPathParam(ctx)
	if ctx.Written() {
		return
	}
	if err := run.LoadAttributes(ctx); err != nil {
		ctx.ServerError("run.LoadAttributes", err)
		return
	}

	resp := &ViewResponse{}
	fillViewRunResponseSummary(ctx, resp, run, jobs)
	if ctx.Written() {
		return
	}
	fillViewRunResponseCurrentJob(ctx, resp, run, jobs)
	if ctx.Written() {
		return
	}
	ctx.JSON(http.StatusOK, resp)
}

func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) {
	var err error
	resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, run.ID)
	if err != nil {
		ctx.ServerError("getActionsViewArtifacts", err)
		return
	}

	resp.State.Run.RepoID = ctx.Repo.Repository.ID
	// the title for the "run" is from the commit message
	resp.State.Run.Title = run.Title
	resp.State.Run.TitleHTML = templates.NewRenderUtils(ctx).RenderCommitMessage(run.Title, ctx.Repo.Repository)
	resp.State.Run.Link = run.Link()
	resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
	resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions)
	resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
	resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
	if resp.State.Run.CanRerun {
		for _, job := range jobs {
			if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled {
				resp.State.Run.CanRerunFailed = true
				break
			}
		}
	}
	resp.State.Run.Done = run.Status.IsDone()
	resp.State.Run.WorkflowID = run.WorkflowID
	resp.State.Run.WorkflowLink = run.WorkflowLink()
	resp.State.Run.IsSchedule = run.IsSchedule()
	resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead fo 'null' in json
	resp.State.Run.Status = run.Status.String()
	for _, v := range jobs {
		resp.State.Run.Jobs = append(resp.State.Run.Jobs, &ViewJob{
			ID:       v.ID,
			JobID:    v.JobID,
			Name:     v.Name,
			Status:   v.Status.String(),
			CanRerun: resp.State.Run.CanRerun,
			Duration: v.Duration().String(),
			Needs:    v.Needs,
		})
	}

	pusher := ViewUser{
		DisplayName: run.TriggerUser.GetDisplayName(),
		Link:        run.TriggerUser.HomeLink(),
	}
	branch := ViewBranch{
		Name: run.PrettyRef(),
		Link: run.RefLink(),
	}
	refName := git.RefName(run.Ref)
	if refName.IsBranch() {
		b, err := git_model.GetBranch(ctx, ctx.Repo.Repository.ID, refName.ShortName())
		if err != nil && !git_model.IsErrBranchNotExist(err) {
			log.Error("GetBranch: %v", err)
		} else if git_model.IsErrBranchNotExist(err) || (b != nil && b.IsDeleted) {
			branch.IsDeleted = true
		}
	}

	resp.State.Run.Commit = ViewCommit{
		ShortSha: base.ShortSha(run.CommitSHA),
		Link:     fmt.Sprintf("%s/commit/%s", run.Repo.Link(), run.CommitSHA),
		Pusher:   pusher,
		Branch:   branch,
	}
	resp.State.Run.Duration = run.Duration().String()
	resp.State.Run.TriggeredAt = run.Created.AsTime().Unix()
	resp.State.Run.TriggerEvent = run.TriggerEvent
}

func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) {
	req := web.GetForm(ctx).(*ViewRequest)
	current, hasPathParam := findCurrentJobByPathParam(ctx, jobs)
	if current == nil {
		if hasPathParam {
			ctx.NotFound(nil)
		}
		return
	}

	var task *actions_model.ActionTask
	if current.TaskID > 0 {
		var err error
		task, err = actions_model.GetTaskByID(ctx, current.TaskID)
		if err != nil {
			ctx.ServerError("actions_model.GetTaskByID", err)
			return
		}
		task.Job = current
		if err := task.LoadAttributes(ctx); err != nil {
			ctx.ServerError("task.LoadAttributes", err)
			return
		}
	}

	resp.State.CurrentJob.Title = current.Name
	resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale)
	if run.NeedApproval {
		resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc")
	}
	resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead fo 'null' in json
	resp.Logs.StepsLog = make([]*ViewStepLog, 0)          // marshal to '[]' instead fo 'null' in json
	if task != nil {
		steps, logs, err := convertToViewModel(ctx, ctx.Locale, req.LogCursors, task)
		if err != nil {
			ctx.ServerError("convertToViewModel", err)
			return
		}
		resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, steps...)
		resp.Logs.StepsLog = append(resp.Logs.StepsLog, logs...)
	}
}

func convertToViewModel(ctx context.Context, locale translation.Locale, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) {
	var viewJobs []*ViewJobStep
	var logs []*ViewStepLog

	steps := actions.FullSteps(task)

	for _, v := range steps {
		viewJobs = append(viewJobs, &ViewJobStep{
			Summary:  v.Name,
			Duration: v.Duration().String(),
			Status:   v.Status.String(),
		})
	}

	for _, cursor := range cursors {
		if !cursor.Expanded {
			continue
		}

		step := steps[cursor.Step]

		// if task log is expired, return a consistent log line
		if task.LogExpired {
			if cursor.Cursor == 0 {
				logs = append(logs, &ViewStepLog{
					Step:   cursor.Step,
					Cursor: 1,
					Lines: []*ViewStepLogLine{
						{
							Index:   1,
							Message: locale.TrString("actions.runs.expire_log_message"),
							// Timestamp doesn't mean anything when the log is expired.
							// Set it to the task's updated time since it's probably the time when the log has expired.
							Timestamp: float64(task.Updated.AsTime().UnixNano()) / float64(time.Second),
						},
					},
					Started: int64(step.Started),
				})
			}
			continue
		}

		logLines := make([]*ViewStepLogLine, 0) // marshal to '[]' instead fo 'null' in json

		index := step.LogIndex + cursor.Cursor
		validCursor := cursor.Cursor >= 0 &&
			// !(cursor.Cursor < step.LogLength) when the frontend tries to fetch next line before it's ready.
			// So return the same cursor and empty lines to let the frontend retry.
			cursor.Cursor < step.LogLength &&
			// !(index < task.LogIndexes[index]) when task data is older than step data.
			// It can be fixed by making sure write/read tasks and steps in the same transaction,
			// but it's easier to just treat it as fetching the next line before it's ready.
			index < int64(len(task.LogIndexes))

		if validCursor {
			length := step.LogLength - cursor.Cursor
			offset := task.LogIndexes[index]
			logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length)
			if err != nil {
				return nil, nil, fmt.Errorf("actions.ReadLogs: %w", err)
			}

			for i, row := range logRows {
				logLines = append(logLines, &ViewStepLogLine{
					Index:     cursor.Cursor + int64(i) + 1, // start at 1
					Message:   row.Content,
					Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second),
				})
			}
		}

		logs = append(logs, &ViewStepLog{
			Step:    cursor.Step,
			Cursor:  cursor.Cursor + int64(len(logLines)),
			Lines:   logLines,
			Started: int64(step.Started),
		})
	}

	return viewJobs, logs, nil
}

// checkRunRerunAllowed checks whether a rerun is permitted for the given run,
// writing the appropriate JSON error to ctx and returning false when it is not.
func checkRunRerunAllowed(ctx *context_module.Context, run *actions_model.ActionRun) bool {
	if !run.Status.IsDone() {
		ctx.JSONError(ctx.Locale.Tr("actions.runs.not_done"))
		return false
	}
	cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
	cfg := cfgUnit.ActionsConfig()
	if cfg.IsWorkflowDisabled(run.WorkflowID) {
		ctx.JSONError(ctx.Locale.Tr("actions.workflow.disabled"))
		return false
	}
	return true
}

// Rerun will rerun jobs in the given run
// If jobIDStr is a blank string, it means rerun all jobs
func Rerun(ctx *context_module.Context) {
	run, jobs := getCurrentRunJobsByPathParam(ctx)
	if ctx.Written() {
		return
	}
	if !checkRunRerunAllowed(ctx, run) {
		return
	}

	currentJob, hasPathParam := findCurrentJobByPathParam(ctx, jobs)
	if hasPathParam && currentJob == nil {
		ctx.NotFound(nil)
		return
	}

	var jobsToRerun []*actions_model.ActionRunJob
	if currentJob != nil {
		jobsToRerun = actions_service.GetAllRerunJobs(currentJob, jobs)
	} else {
		jobsToRerun = jobs
	}

	if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobsToRerun); err != nil {
		ctx.ServerError("RerunWorkflowRunJobs", err)
		return
	}

	ctx.JSONOK()
}

// RerunFailed reruns all failed jobs in the given run
func RerunFailed(ctx *context_module.Context) {
	run, jobs := getCurrentRunJobsByPathParam(ctx)
	if ctx.Written() {
		return
	}
	if !checkRunRerunAllowed(ctx, run) {
		return
	}

	if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetFailedRerunJobs(jobs)); err != nil {
		ctx.ServerError("RerunWorkflowRunJobs", err)
		return
	}

	ctx.JSONOK()
}

func Logs(ctx *context_module.Context) {
	run := getCurrentRunByPathParam(ctx)
	if ctx.Written() {
		return
	}
	jobID := ctx.PathParamInt64("job")

	if err := common.DownloadActionsRunJobLogsWithID(ctx.Base, ctx.Repo.Repository, run.ID, jobID); err != nil {
		ctx.NotFoundOrServerError("DownloadActionsRunJobLogsWithID", func(err error) bool {
			return errors.Is(err, util.ErrNotExist)
		}, err)
	}
}

func Cancel(ctx *context_module.Context) {
	run, jobs := getCurrentRunJobsByPathParam(ctx)
	if ctx.Written() {
		return
	}

	var updatedJobs []*actions_model.ActionRunJob

	if err := db.WithTx(ctx, func(ctx context.Context) error {
		cancelledJobs, err := actions_model.CancelJobs(ctx, jobs)
		if err != nil {
			return fmt.Errorf("cancel jobs: %w", err)
		}
		updatedJobs = append(updatedJobs, cancelledJobs...)
		return nil
	}); err != nil {
		ctx.ServerError("StopTask", err)
		return
	}

	actions_service.CreateCommitStatusForRunJobs(ctx, run, jobs...)
	actions_service.EmitJobsIfReadyByJobs(updatedJobs)

	for _, job := range updatedJobs {
		_ = job.LoadAttributes(ctx)
		notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
	}
	if len(updatedJobs) > 0 {
		job := updatedJobs[0]
		actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
	}
	ctx.JSONOK()
}

func Approve(ctx *context_module.Context) {
	run := getCurrentRunByPathParam(ctx)
	if ctx.Written() {
		return
	}
	approveRuns(ctx, []int64{run.ID})
	if ctx.Written() {
		return
	}

	ctx.JSONOK()
}

func approveRuns(ctx *context_module.Context, runIDs []int64) {
	doer := ctx.Doer
	repo := ctx.Repo.Repository

	updatedJobs := make([]*actions_model.ActionRunJob, 0)
	runMap := make(map[int64]*actions_model.ActionRun, len(runIDs))
	runJobs := make(map[int64][]*actions_model.ActionRunJob, len(runIDs))

	err := db.WithTx(ctx, func(ctx context.Context) (err error) {
		for _, runID := range runIDs {
			run, err := actions_model.GetRunByRepoAndID(ctx, repo.ID, runID)
			if err != nil {
				return err
			}
			runMap[run.ID] = run
			run.Repo = repo
			run.NeedApproval = false
			run.ApprovedBy = doer.ID
			if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
				return err
			}
			jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
			if err != nil {
				return err
			}
			runJobs[run.ID] = jobs
			for _, job := range jobs {
				job.Status, err = actions_service.PrepareToStartJobWithConcurrency(ctx, job)
				if err != nil {
					return err
				}
				if job.Status == actions_model.StatusWaiting {
					n, err := actions_model.UpdateRunJob(ctx, job, nil, "status")
					if err != nil {
						return err
					}
					if n > 0 {
						updatedJobs = append(updatedJobs, job)
					}
				}
			}
		}
		return nil
	})
	if err != nil {
		ctx.NotFoundOrServerError("approveRuns", func(err error) bool {
			return errors.Is(err, util.ErrNotExist)
		}, err)
		return
	}

	for runID, run := range runMap {
		actions_service.CreateCommitStatusForRunJobs(ctx, run, runJobs[runID]...)
	}

	if len(updatedJobs) > 0 {
		job := updatedJobs[0]
		actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
	}

	for _, job := range updatedJobs {
		_ = job.LoadAttributes(ctx)
		notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
	}
}

func Delete(ctx *context_module.Context) {
	run := getCurrentRunByPathParam(ctx)
	if ctx.Written() {
		return
	}

	if !run.Status.IsDone() {
		ctx.JSONError(ctx.Tr("actions.runs.not_done"))
		return
	}

	if err := actions_service.DeleteRun(ctx, run); err != nil {
		ctx.ServerError("DeleteRun", err)
		return
	}

	ctx.JSONOK()
}

// getRunJobs loads the run and its jobs for runID
// Any error will be written to the ctx, empty jobs will also result in 404 error, then the return values are all nil.
func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.ActionRun, []*actions_model.ActionRunJob) {
	run := getCurrentRunByPathParam(ctx)
	if ctx.Written() {
		return nil, nil
	}
	run.Repo = ctx.Repo.Repository
	jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
	if err != nil {
		ctx.ServerError("GetRunJobsByRunID", err)
		return nil, nil
	}
	if len(jobs) == 0 {
		ctx.NotFound(nil)
		return nil, nil
	}

	for _, job := range jobs {
		job.Run = run
	}
	return run, jobs
}

func ArtifactsDeleteView(ctx *context_module.Context) {
	run := getCurrentRunByPathParam(ctx)
	if ctx.Written() {
		return
	}
	artifactName := ctx.PathParam("artifact_name")
	if err := actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil {
		ctx.ServerError("SetArtifactNeedDelete", err)
		return
	}
	ctx.JSON(http.StatusOK, struct{}{})
}

func ArtifactsDownloadView(ctx *context_module.Context) {
	run := getCurrentRunByPathParam(ctx)
	if ctx.Written() {
		return
	}

	artifactName := ctx.PathParam("artifact_name")
	artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
		RunID:        run.ID,
		ArtifactName: artifactName,
	})
	if err != nil {
		ctx.ServerError("FindArtifacts", err)
		return
	}
	if len(artifacts) == 0 {
		ctx.HTTPError(http.StatusNotFound, "artifact not found")
		return
	}

	// if artifacts status is not uploaded-confirmed, treat it as not found
	for _, art := range artifacts {
		if art.Status != actions_model.ArtifactStatusUploadConfirmed {
			ctx.HTTPError(http.StatusNotFound, "artifact not found")
			return
		}
	}

	// A v4 Artifact may only contain a single file
	// Multiple files are uploaded as a single file archive
	// All other cases fall back to the legacy v1–v3 zip handling below
	if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) {
		err := actions.DownloadArtifactV4(ctx.Base, artifacts[0])
		if err != nil {
			ctx.ServerError("DownloadArtifactV4", err)
			return
		}
		return
	}

	// Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend
	// Those need to be zipped for download
	ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment(artifactName+".zip"))
	zipWriter := zip.NewWriter(ctx.Resp)
	defer zipWriter.Close()

	writeArtifactToZip := func(art *actions_model.ActionArtifact) error {
		f, err := storage.ActionsArtifacts.Open(art.StoragePath)
		if err != nil {
			return fmt.Errorf("ActionsArtifacts.Open: %w", err)
		}
		defer f.Close()

		var r io.ReadCloser = f
		if art.ContentEncodingOrType == actions_model.ContentEncodingV3Gzip {
			r, err = gzip.NewReader(f)
			if err != nil {
				return fmt.Errorf("gzip.NewReader: %w", err)
			}
		}
		defer r.Close()

		w, err := zipWriter.Create(art.ArtifactPath)
		if err != nil {
			return fmt.Errorf("zipWriter.Create: %w", err)
		}
		_, err = io.Copy(w, r)
		if err != nil {
			return fmt.Errorf("io.Copy: %w", err)
		}
		return nil
	}

	for _, art := range artifacts {
		err := writeArtifactToZip(art)
		if err != nil {
			ctx.ServerError("writeArtifactToZip", err)
			return
		}
	}
}

func ApproveAllChecks(ctx *context_module.Context) {
	repo := ctx.Repo.Repository
	commitID := ctx.FormString("commit_id")

	commitStatuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll)
	if err != nil {
		ctx.ServerError("GetLatestCommitStatus", err)
		return
	}
	runs, err := actions_service.GetRunsFromCommitStatuses(ctx, commitStatuses)
	if err != nil {
		ctx.ServerError("GetRunsFromCommitStatuses", err)
		return
	}

	runIDs := make([]int64, 0, len(runs))
	for _, run := range runs {
		if run.NeedApproval {
			runIDs = append(runIDs, run.ID)
		}
	}

	if len(runIDs) == 0 {
		ctx.JSONOK()
		return
	}

	approveRuns(ctx, runIDs)
	if ctx.Written() {
		return
	}

	ctx.Flash.Success(ctx.Tr("actions.approve_all_success"))
	ctx.JSONOK()
}

func DisableWorkflowFile(ctx *context_module.Context) {
	disableOrEnableWorkflowFile(ctx, false)
}

func EnableWorkflowFile(ctx *context_module.Context) {
	disableOrEnableWorkflowFile(ctx, true)
}

func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) {
	workflow := ctx.FormString("workflow")
	if len(workflow) == 0 {
		ctx.JSONError("workflow is required")
		return
	}

	cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
	cfg := cfgUnit.ActionsConfig()

	if isEnable {
		cfg.EnableWorkflow(workflow)
	} else {
		cfg.DisableWorkflow(workflow)
	}

	if err := repo_model.UpdateRepoUnitConfig(ctx, cfgUnit); err != nil {
		ctx.ServerError("UpdateRepoUnit", err)
		return
	}

	if isEnable {
		ctx.Flash.Success(ctx.Tr("actions.workflow.enable_success", workflow))
	} else {
		ctx.Flash.Success(ctx.Tr("actions.workflow.disable_success", workflow))
	}

	redirectURL := fmt.Sprintf("%s/actions?workflow=%s&actor=%s&status=%s", ctx.Repo.RepoLink, url.QueryEscape(workflow),
		url.QueryEscape(ctx.FormString("actor")), url.QueryEscape(ctx.FormString("status")))
	ctx.JSONRedirect(redirectURL)
}

func Run(ctx *context_module.Context) {
	redirectURL := fmt.Sprintf("%s/actions?workflow=%s&actor=%s&status=%s", ctx.Repo.RepoLink, url.QueryEscape(ctx.FormString("workflow")),
		url.QueryEscape(ctx.FormString("actor")), url.QueryEscape(ctx.FormString("status")))

	workflowID := ctx.FormString("workflow")
	if len(workflowID) == 0 {
		ctx.ServerError("workflow", nil)
		return
	}

	ref := ctx.FormString("ref")
	if len(ref) == 0 {
		ctx.ServerError("ref", nil)
		return
	}
	_, err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error {
		for name, config := range workflowDispatch.Inputs {
			value := ctx.Req.PostFormValue(name)
			if config.Type == "boolean" {
				inputs[name] = strconv.FormatBool(ctx.FormBool(name))
			} else if value != "" {
				inputs[name] = value
			} else {
				inputs[name] = config.Default
			}
		}
		return nil
	})
	if err != nil {
		if errTr := util.ErrorAsTranslatable(err); errTr != nil {
			ctx.Flash.Error(errTr.Translate(ctx.Locale))
			ctx.Redirect(redirectURL)
		} else {
			ctx.ServerError("DispatchActionWorkflow", err)
		}
		return
	}

	ctx.Flash.Success(ctx.Tr("actions.workflow.run_success", workflowID))
	ctx.Redirect(redirectURL)
}
