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

package v1_26

import (
	"errors"
	"fmt"
	"net/url"
	"strconv"
	"strings"

	"gitea.dev/models/db"
	"gitea.dev/modules/json"
	"gitea.dev/modules/log"
	"gitea.dev/modules/setting"
	webhook_module "gitea.dev/modules/webhook"
)

const (
	actionsRunPath = "/actions/runs/"

	// Only commit status target URLs whose resolved run ID is smaller than this threshold are rewritten by this partial migration.
	// The fixed value 1000 is a conservative cutoff chosen to cover the smaller legacy run indexes that are most likely to be confused with ID-based URLs at runtime.
	// Larger legacy {run} or {job} numbers are usually easier to disambiguate. For example:
	//   * /actions/runs/1200/jobs/1420 is most likely an ID-based URL, because a run should not contain more than 256 jobs.
	//   * /actions/runs/1500/jobs/3 is most likely an index-based URL, because a job ID cannot be smaller than its run ID.
	// But URLs with small numbers, such as /actions/runs/5/jobs/6, are much harder to distinguish reliably.
	// This migration therefore prioritizes rewriting target URLs for runs in that lower range.
	legacyURLIDThreshold int64 = 1000
)

type migrationRepository struct {
	ID        int64
	OwnerName string
	Name      string
}

type migrationActionRun struct {
	ID           int64
	RepoID       int64
	Index        int64
	CommitSHA    string `xorm:"commit_sha"`
	Event        webhook_module.HookEventType
	TriggerEvent string
	EventPayload string
}

type migrationActionRunJob struct {
	ID    int64
	RunID int64
}

type migrationCommitStatus struct {
	ID        int64
	RepoID    int64
	TargetURL string
}

// Frozen subsets of modules/structs payload types, decoded from stored
// action_run.event_payload values. Inlined so the migration is insulated
// from future field changes in modules/structs.
type migrationPayloadCommit struct {
	ID string `json:"id"`
}

type migrationPushPayload struct {
	HeadCommit *migrationPayloadCommit `json:"head_commit"`
}

type migrationPRBranchInfo struct {
	Sha string `json:"sha"`
}

type migrationPullRequest struct {
	Head *migrationPRBranchInfo `json:"head"`
}

type migrationPullRequestPayload struct {
	PullRequest *migrationPullRequest `json:"pull_request"`
}

type commitSHAAndRuns struct {
	commitSHA string
	runs      map[int64]*migrationActionRun
}

// FixCommitStatusTargetURLToUseRunAndJobID partially migrates legacy Actions
// commit status target URLs to the new run/job ID-based form.
//
// Only rows whose resolved run ID is below legacyURLIDThreshold are rewritten.
// This is because smaller legacy run indexes are more likely to collide with run ID URLs during runtime resolution,
// so this migration prioritizes that lower range and leaves the remaining legacy target URLs to the web compatibility logic.
func FixCommitStatusTargetURLToUseRunAndJobID(x db.EngineMigration) error {
	jobsByRunIDCache := make(map[int64][]int64)
	repoLinkCache := make(map[int64]string)
	groups, err := loadLegacyMigrationRunGroups(x)
	if err != nil {
		return err
	}

	for repoID, groupsBySHA := range groups {
		for _, group := range groupsBySHA {
			if err := migrateCommitStatusTargetURLForGroup(x, "commit_status", repoID, group.commitSHA, group.runs, jobsByRunIDCache, repoLinkCache); err != nil {
				return err
			}
			if err := migrateCommitStatusTargetURLForGroup(x, "commit_status_summary", repoID, group.commitSHA, group.runs, jobsByRunIDCache, repoLinkCache); err != nil {
				return err
			}
		}
	}
	return nil
}

func loadLegacyMigrationRunGroups(x db.EngineMigration) (map[int64]map[string]*commitSHAAndRuns, error) {
	var runs []migrationActionRun
	if err := x.Table("action_run").
		Where("id < ?", legacyURLIDThreshold).
		Cols("id", "repo_id", "`index`", "commit_sha", "event", "trigger_event", "event_payload").
		Find(&runs); err != nil {
		return nil, fmt.Errorf("query action_run: %w", err)
	}

	groups := make(map[int64]map[string]*commitSHAAndRuns)
	for i := range runs {
		run := runs[i]
		commitID, err := getCommitStatusCommitID(&run)
		if err != nil {
			log.Warn("skip action_run id=%d when resolving commit status commit SHA: %v", run.ID, err)
			continue
		}
		if commitID == "" {
			// empty commitID means the run didn't create any commit status records, just skip
			continue
		}
		if groups[run.RepoID] == nil {
			groups[run.RepoID] = make(map[string]*commitSHAAndRuns)
		}
		if groups[run.RepoID][commitID] == nil {
			groups[run.RepoID][commitID] = &commitSHAAndRuns{
				commitSHA: commitID,
				runs:      make(map[int64]*migrationActionRun),
			}
		}
		groups[run.RepoID][commitID].runs[run.Index] = &run
	}
	return groups, nil
}

func migrateCommitStatusTargetURLForGroup(
	x db.EngineMigration,
	table string,
	repoID int64,
	sha string,
	runs map[int64]*migrationActionRun,
	jobsByRunIDCache map[int64][]int64,
	repoLinkCache map[int64]string,
) error {
	var rows []migrationCommitStatus
	if err := x.Table(table).
		Where("repo_id = ?", repoID).
		And("sha = ?", sha).
		Cols("id", "repo_id", "target_url").
		Find(&rows); err != nil {
		return fmt.Errorf("query %s for repo_id=%d sha=%s: %w", table, repoID, sha, err)
	}

	for _, row := range rows {
		repoLink, err := getRepoLinkCached(x, repoLinkCache, row.RepoID)
		if err != nil || repoLink == "" {
			if err != nil {
				log.Warn("convert %s id=%d getRepoLinkCached: %v", table, row.ID, err)
			} else {
				log.Warn("convert %s id=%d: repo=%d not found", table, row.ID, row.RepoID)
			}
			continue
		}

		runNum, jobNum, ok := parseTargetURL(row.TargetURL, repoLink)
		if !ok {
			continue
		}

		run, ok := runs[runNum]
		if !ok {
			continue
		}

		jobID, ok, err := getJobIDByIndexCached(x, jobsByRunIDCache, run.ID, jobNum)
		if err != nil || !ok {
			if err != nil {
				log.Warn("convert %s id=%d getJobIDByIndexCached: %v", table, row.ID, err)
			} else {
				log.Warn("convert %s id=%d: job not found for run_id=%d job_index=%d", table, row.ID, run.ID, jobNum)
			}
			continue
		}

		oldURL := row.TargetURL
		newURL := fmt.Sprintf("%s%s%d/jobs/%d", repoLink, actionsRunPath, run.ID, jobID)
		if oldURL == newURL {
			continue
		}

		if _, err := x.Table(table).ID(row.ID).Cols("target_url").Update(&migrationCommitStatus{TargetURL: newURL}); err != nil {
			return fmt.Errorf("update %s id=%d target_url from %s to %s: %w", table, row.ID, oldURL, newURL, err)
		}
	}
	return nil
}

func getRepoLinkCached(x db.EngineMigration, cache map[int64]string, repoID int64) (string, error) {
	if link, ok := cache[repoID]; ok {
		return link, nil
	}
	repo := &migrationRepository{}
	has, err := x.Table("repository").Where("id=?", repoID).Get(repo)
	if err != nil {
		return "", err
	}
	if !has {
		cache[repoID] = ""
		return "", nil
	}
	link := setting.AppSubURL + "/" + url.PathEscape(repo.OwnerName) + "/" + url.PathEscape(repo.Name)
	cache[repoID] = link
	return link, nil
}

func getJobIDByIndexCached(x db.EngineMigration, cache map[int64][]int64, runID, jobIndex int64) (int64, bool, error) {
	jobIDs, ok := cache[runID]
	if !ok {
		var jobs []migrationActionRunJob
		if err := x.Table("action_run_job").Where("run_id=?", runID).Asc("id").Cols("id").Find(&jobs); err != nil {
			return 0, false, err
		}
		jobIDs = make([]int64, 0, len(jobs))
		for _, job := range jobs {
			jobIDs = append(jobIDs, job.ID)
		}
		cache[runID] = jobIDs
	}
	if jobIndex < 0 || jobIndex >= int64(len(jobIDs)) {
		return 0, false, nil
	}
	return jobIDs[jobIndex], true, nil
}

func parseTargetURL(targetURL, repoLink string) (runNum, jobNum int64, ok bool) {
	prefix := repoLink + actionsRunPath
	if !strings.HasPrefix(targetURL, prefix) {
		return 0, 0, false
	}
	rest := targetURL[len(prefix):]

	parts := strings.Split(rest, "/")
	if len(parts) == 3 && parts[1] == "jobs" {
		runNum, err1 := strconv.ParseInt(parts[0], 10, 64)
		jobNum, err2 := strconv.ParseInt(parts[2], 10, 64)
		if err1 != nil || err2 != nil {
			return 0, 0, false
		}
		return runNum, jobNum, true
	}

	return 0, 0, false
}

func getCommitStatusCommitID(run *migrationActionRun) (string, error) {
	switch run.Event {
	case webhook_module.HookEventPush:
		payload, err := getPushEventPayload(run)
		if err != nil {
			return "", fmt.Errorf("getPushEventPayload: %w", err)
		}
		if payload.HeadCommit == nil {
			return "", errors.New("head commit is missing in event payload")
		}
		return payload.HeadCommit.ID, nil
	case webhook_module.HookEventPullRequest,
		webhook_module.HookEventPullRequestSync,
		webhook_module.HookEventPullRequestAssign,
		webhook_module.HookEventPullRequestLabel,
		webhook_module.HookEventPullRequestReviewRequest,
		webhook_module.HookEventPullRequestMilestone:
		payload, err := getPullRequestEventPayload(run)
		if err != nil {
			return "", fmt.Errorf("getPullRequestEventPayload: %w", err)
		}
		if payload.PullRequest == nil {
			return "", errors.New("pull request is missing in event payload")
		} else if payload.PullRequest.Head == nil {
			return "", errors.New("head of pull request is missing in event payload")
		}
		return payload.PullRequest.Head.Sha, nil
	case webhook_module.HookEventPullRequestReviewApproved,
		webhook_module.HookEventPullRequestReviewRejected,
		webhook_module.HookEventPullRequestReviewComment:
		payload, err := getPullRequestEventPayload(run)
		if err != nil {
			return "", fmt.Errorf("getPullRequestEventPayload: %w", err)
		}
		if payload.PullRequest == nil {
			return "", errors.New("pull request is missing in event payload")
		} else if payload.PullRequest.Head == nil {
			return "", errors.New("head of pull request is missing in event payload")
		}
		return payload.PullRequest.Head.Sha, nil
	case webhook_module.HookEventRelease:
		return run.CommitSHA, nil
	default:
		return "", nil
	}
}

func getPushEventPayload(run *migrationActionRun) (*migrationPushPayload, error) {
	if run.Event != webhook_module.HookEventPush {
		return nil, fmt.Errorf("event %s is not a push event", run.Event)
	}
	var payload migrationPushPayload
	if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
		return nil, err
	}
	return &payload, nil
}

func getPullRequestEventPayload(run *migrationActionRun) (*migrationPullRequestPayload, error) {
	if !run.Event.IsPullRequest() && !run.Event.IsPullRequestReview() {
		return nil, fmt.Errorf("event %s is not a pull request event", run.Event)
	}
	var payload migrationPullRequestPayload
	if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
		return nil, err
	}
	return &payload, nil
}
