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

package actions

import (
	"testing"

	actions_model "gitea.dev/models/actions"
	"gitea.dev/models/db"
	git_model "gitea.dev/models/git"
	repo_model "gitea.dev/models/repo"
	"gitea.dev/models/unittest"
	user_model "gitea.dev/models/user"
	actions_module "gitea.dev/modules/actions"
	"gitea.dev/modules/commitstatus"
	"gitea.dev/modules/git"
	"gitea.dev/modules/gitrepo"
	"gitea.dev/modules/timeutil"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestCommitStatusDescription(t *testing.T) {
	cases := []struct {
		status           actions_model.Status
		started, stopped timeutil.TimeStamp
		want             string
	}{
		{actions_model.StatusSuccess, 100, 102, "Successful in 2s"},
		{actions_model.StatusFailure, 100, 130, "Failing after 30s"},
		{actions_model.StatusCancelled, 100, 145, "Canceled after 45s"},
		{actions_model.StatusCancelling, 0, 0, "Canceling"},
		{actions_model.StatusSkipped, 0, 0, "Skipped"},
		{actions_model.StatusRunning, 0, 0, "In progress"},
		{actions_model.StatusWaiting, 0, 0, "Waiting to run"},
		{actions_model.StatusBlocked, 0, 0, "Blocked by required conditions"},
		{actions_model.StatusUnknown, 0, 0, "Unknown status: 0"},
	}
	for _, tc := range cases {
		job := &actions_model.ActionRunJob{Status: tc.status, Started: tc.started, Stopped: tc.stopped}
		assert.Equal(t, tc.want, toCommitStatusDescription(job), tc.status.String())
	}
}

func TestCreateCommitStatus_Dedupe(t *testing.T) {
	assert.NoError(t, unittest.PrepareTestDatabase())

	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
	gitRepo, err := gitrepo.OpenRepository(t.Context(), repo)
	require.NoError(t, err)
	defer gitRepo.Close()

	commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
	require.NoError(t, err)

	run := &actions_model.ActionRun{
		ID:         99001,
		RepoID:     repo.ID,
		Repo:       repo,
		WorkflowID: "status-dedupe-test.yaml",
	}
	job := &actions_model.ActionRunJob{
		ID:     99002,
		RunID:  run.ID,
		RepoID: repo.ID,
		Name:   "status-dedupe-job",
		Status: actions_model.StatusWaiting,
	}

	expectedContext := "status-dedupe-test.yaml / status-dedupe-job (push)"
	expectedTargetURL := run.Link() + "/jobs/99002"

	require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))

	statuses := findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
	require.Len(t, statuses, 1)
	assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
	assert.Equal(t, "Waiting to run", statuses[0].Description)
	assert.Equal(t, expectedTargetURL, statuses[0].TargetURL)

	job.Status = actions_model.StatusRunning
	require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))

	statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
	require.Len(t, statuses, 2)
	assert.Equal(t, "Waiting to run", statuses[0].Description)
	assert.Equal(t, commitstatus.CommitStatusPending, statuses[1].State)
	assert.Equal(t, "In progress", statuses[1].Description)
	assert.Equal(t, expectedTargetURL, statuses[1].TargetURL)

	require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))
	statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
	assert.Len(t, statuses, 2)

	job.Status = actions_model.StatusSuccess
	require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))
	statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
	require.Len(t, statuses, 3)
	assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
}

func TestGetCommitActionsStatusMap(t *testing.T) {
	assert.NoError(t, unittest.PrepareTestDatabase())

	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
	branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})

	run := &actions_model.ActionRun{
		RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
		WorkflowID: "test.yaml", CommitSHA: branch.CommitID,
	}
	require.NoError(t, db.Insert(t.Context(), run))

	cases := []struct {
		jobName string
		status  actions_model.Status
	}{
		{"running-job", actions_model.StatusRunning},
		{"waiting-job", actions_model.StatusWaiting},
		{"unknown-job", actions_model.StatusUnknown},
	}
	for _, tc := range cases {
		job := &actions_model.ActionRunJob{
			RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID, Name: tc.jobName, Status: tc.status,
		}
		require.NoError(t, db.Insert(t.Context(), job))
		require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
	}

	statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
	require.NoError(t, err)

	info := actions_module.GetCommitActionsStatusMap(t.Context(), statuses)
	got := map[string]string{}
	for _, s := range statuses {
		got[s.Context] = info.IconStatus(s)
	}
	for _, tc := range cases {
		key := "test.yaml / " + tc.jobName + " (push)"
		want := tc.status.String()
		assert.Equal(t, want, got[key], "icon status for %s", tc.jobName)
	}

	// Nil receiver returns "" without panicking — used by callers that skip enrichment.
	var nilInfo actions_module.CommitActionsStatusMap
	assert.Empty(t, nilInfo.IconStatus(statuses[0]))
}

// TestCreateCommitStatus_DistinctWorkflowFilesSameName covers issue #35699:
// two workflow files with the same `name:` and same job name must produce
// two distinct commit statuses, not be deduplicated into one.
func TestCreateCommitStatus_DistinctWorkflowFilesSameName(t *testing.T) {
	assert.NoError(t, unittest.PrepareTestDatabase())

	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
	branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})

	payload := []byte(`
name: test-run
on: pull_request
jobs:
  my-test:
    runs-on: ubuntu-latest
    steps:
      - run: echo hi
`)

	for _, spec := range []struct {
		workflowID   string
		runID, jobID int64
	}{
		{"workflow1.yaml", 99101, 99201},
		{"workflow2.yaml", 99102, 99202},
	} {
		run := &actions_model.ActionRun{
			ID: spec.runID, Index: spec.runID, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
			WorkflowID: spec.workflowID, CommitSHA: branch.CommitID,
		}
		require.NoError(t, db.Insert(t.Context(), run))
		job := &actions_model.ActionRunJob{
			ID: spec.jobID, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
			Name: "my-test", Status: actions_model.StatusWaiting,
			WorkflowPayload: payload,
		}
		require.NoError(t, db.Insert(t.Context(), job))
		require.NoError(t, createCommitStatus(t.Context(), repo, "pull_request", branch.CommitID, "", run, job))
	}

	statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
	require.NoError(t, err)

	// Both workflow files should produce a row even though the display
	// Context is identical — matching GitHub's behavior.
	hashes := map[string]struct{}{}
	targets := map[string]struct{}{}
	for _, st := range statuses {
		hashes[st.ContextHash] = struct{}{}
		targets[st.TargetURL] = struct{}{}
		assert.Equal(t, "test-run / my-test (pull_request)", st.Context)
	}
	assert.Len(t, hashes, 2, "expected distinct ContextHash per workflow file")
	assert.Len(t, targets, 2, "expected distinct TargetURL per workflow file")
}

// TestCreateCommitStatus_LegacyHashRecovery covers the upgrade path: a pending
// status created before the fix (hashed from Context alone) must still be
// superseded by a follow-up event, instead of being orphaned in its own dedupe
// group while a new row accumulates under the new hash.
func TestCreateCommitStatus_LegacyHashRecovery(t *testing.T) {
	assert.NoError(t, unittest.PrepareTestDatabase())

	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
	branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})

	ctxName := "legacy.yaml / my-job (push)"
	legacyHash := git_model.HashCommitStatusContext(ctxName)
	sha, err := git.NewIDFromString(branch.CommitID)
	require.NoError(t, err)
	// Pre-#35699 in-flight rows were posted by the Actions user with the Context-only hash.
	creator := user_model.NewActionsUser()
	require.NoError(t, git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{
		Repo:    repo,
		Creator: creator,
		SHA:     sha,
		CommitStatus: &git_model.CommitStatus{
			State:       commitstatus.CommitStatusPending,
			Context:     ctxName,
			ContextHash: legacyHash,
			TargetURL:   "https://example.invalid/legacy",
			Description: "Waiting to run",
		},
	}))

	run := &actions_model.ActionRun{
		ID: 99301, Index: 99301, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
		WorkflowID: "legacy.yaml", CommitSHA: branch.CommitID,
	}
	require.NoError(t, db.Insert(t.Context(), run))
	job := &actions_model.ActionRunJob{
		ID: 99302, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
		Name: "my-job", Status: actions_model.StatusSuccess,
	}
	require.NoError(t, db.Insert(t.Context(), job))
	require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))

	latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
	require.NoError(t, err)
	// The new row must reuse the legacy hash so GetLatestCommitStatus returns
	// only one entry for this Context — the success, not the orphaned pending.
	matches := 0
	for _, s := range latest {
		if s.Context == ctxName {
			matches++
			assert.Equal(t, legacyHash, s.ContextHash)
			assert.Equal(t, commitstatus.CommitStatusSuccess, s.State)
		}
	}
	assert.Equal(t, 1, matches)
}

// TestCreateCommitStatus_LegacyHashExternalNotAdopted: a status from a non-Actions creator sharing a
// workflow's Context must not pull the workflow into the legacy Context-only hash group.
func TestCreateCommitStatus_LegacyHashExternalNotAdopted(t *testing.T) {
	assert.NoError(t, unittest.PrepareTestDatabase())

	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
	branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})

	workflowID := "external.yaml"
	ctxName := "external.yaml / my-job (push)"
	legacyHash := git_model.HashCommitStatusContext(ctxName)
	distinctHash := git_model.HashCommitStatusContext(ctxName + "\x00" + workflowID)
	sha, err := git.NewIDFromString(branch.CommitID)
	require.NoError(t, err)

	// An external status (posted by a real user, not the Actions user) sharing the same Context.
	externalCreator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
	require.NoError(t, git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{
		Repo:    repo,
		Creator: externalCreator,
		SHA:     sha,
		CommitStatus: &git_model.CommitStatus{
			State:       commitstatus.CommitStatusSuccess,
			Context:     ctxName,
			ContextHash: legacyHash,
			TargetURL:   "https://example.invalid/external",
			Description: "external check",
		},
	}))

	run := &actions_model.ActionRun{
		ID: 99311, Index: 99311, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
		WorkflowID: workflowID, CommitSHA: branch.CommitID,
	}
	require.NoError(t, db.Insert(t.Context(), run))
	job := &actions_model.ActionRunJob{
		ID: 99312, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
		Name: "my-job", Status: actions_model.StatusSuccess,
	}
	require.NoError(t, db.Insert(t.Context(), job))
	require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))

	latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
	require.NoError(t, err)
	// The external status and the workflow status must coexist under distinct hashes.
	var external, workflow *git_model.CommitStatus
	for _, s := range latest {
		switch s.ContextHash {
		case legacyHash:
			external = s
		case distinctHash:
			workflow = s
		}
	}
	require.NotNil(t, external, "external status must be preserved under the legacy hash")
	require.NotNil(t, workflow, "workflow status must use its own distinct hash, not the external legacy hash")
	assert.Equal(t, "https://example.invalid/external", external.TargetURL)
}

// TestCreateCommitStatus_UnnamedWorkflowUsesFileName: a workflow with no
// non-blank `name:` uses the file name in the Context, not an empty
// "/ job (event)" — covers both an omitted and a whitespace-only name.
func TestCreateCommitStatus_UnnamedWorkflowUsesFileName(t *testing.T) {
	assert.NoError(t, unittest.PrepareTestDatabase())

	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
	branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})

	for _, tc := range []struct {
		workflowID   string
		runID, jobID int64
		payload      string
	}{
		{"unnamed.yaml", 99401, 99411, "on: push\n"},
		{"blank.yaml", 99402, 99412, "name: \"   \"\non: push\n"},
	} {
		run := &actions_model.ActionRun{
			ID: tc.runID, Index: tc.runID, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
			WorkflowID: tc.workflowID, CommitSHA: branch.CommitID,
		}
		require.NoError(t, db.Insert(t.Context(), run))
		job := &actions_model.ActionRunJob{
			ID: tc.jobID, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
			Name: "my-test", Status: actions_model.StatusWaiting,
			WorkflowPayload: []byte(tc.payload + `jobs:
  my-test:
    runs-on: ubuntu-latest
    steps:
      - run: echo hi
`),
		}
		require.NoError(t, db.Insert(t.Context(), job))
		require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))

		statuses := findCommitStatusesForContext(t, repo.ID, branch.CommitID, tc.workflowID+" / my-test (push)")
		require.Len(t, statuses, 1)
		assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
	}
}

// TestCreateCommitStatus_ScopedSourcePrefix: a scoped run's commit status Context is prefixed with the source repo's full name,
// so it is distinct (display AND hash) from a same-named repo-level workflow.
func TestCreateCommitStatus_ScopedSourcePrefix(t *testing.T) {
	assert.NoError(t, unittest.PrepareTestDatabase())

	consumer := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
	source := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
	branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: consumer.ID, Name: consumer.DefaultBranch})

	payload := []byte(`name: ci
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: echo hi
`)

	// A repo-level run and a scoped run share the same workflow name and job name;
	// only the scoped one points its content source at another repo (WorkflowRepoID=source.ID, IsScopedRun=true).
	for _, spec := range []struct {
		runID, jobID int64
		scoped       bool
	}{
		{99501, 99511, false},
		{99502, 99512, true},
	} {
		workflowRepoID := consumer.ID
		if spec.scoped {
			workflowRepoID = source.ID
		}
		run := &actions_model.ActionRun{
			ID: spec.runID, Index: spec.runID, RepoID: consumer.ID, Repo: consumer, OwnerID: consumer.OwnerID, TriggerUserID: consumer.OwnerID,
			WorkflowID: "ci.yaml", CommitSHA: branch.CommitID,
			WorkflowRepoID: workflowRepoID, WorkflowCommitSHA: branch.CommitID, IsScopedRun: spec.scoped,
		}
		require.NoError(t, db.Insert(t.Context(), run))
		job := &actions_model.ActionRunJob{
			ID: spec.jobID, RunID: run.ID, RepoID: consumer.ID, OwnerID: consumer.OwnerID,
			Name: "build", Status: actions_model.StatusWaiting, WorkflowPayload: payload,
		}
		require.NoError(t, db.Insert(t.Context(), job))
		// mirror CreateCommitStatusForRunJobs: compute the scoped prefix once per run
		scopedPrefix := ""
		if run.IsScopedRun {
			scopedPrefix = actions_model.ScopedStatusContextPrefix(t.Context(), run.WorkflowRepoID)
		}
		require.NoError(t, createCommitStatus(t.Context(), consumer, "push", branch.CommitID, scopedPrefix, run, job))
	}

	// repo-level Context is the bare "<display name> / <job> (<event>)"; the scoped one is the same but sets off the source repo with a colon,
	// so the two stay distinct (and have different hashes) despite the same `name:`.
	repoStatuses := findCommitStatusesForContext(t, consumer.ID, branch.CommitID, "ci / build (push)")
	require.Len(t, repoStatuses, 1)
	scopedStatuses := findCommitStatusesForContext(t, consumer.ID, branch.CommitID, source.FullName()+": ci / build (push)")
	require.Len(t, scopedStatuses, 1)

	assert.NotEqual(t, repoStatuses[0].ContextHash, scopedStatuses[0].ContextHash,
		"scoped status must not collide with the same-named repo-level workflow")
}

func findCommitStatusesForContext(t *testing.T, repoID int64, sha, context string) []*git_model.CommitStatus {
	t.Helper()

	var statuses []*git_model.CommitStatus
	err := db.GetEngine(t.Context()).
		Where("repo_id = ? AND sha = ? AND context = ?", repoID, sha, context).
		Asc("`index`").
		Find(&statuses)
	require.NoError(t, err)
	return statuses
}
