// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later

package integration

import (
	"net/url"
	"strings"
	"testing"

	actions_model "forgejo.org/models/actions"
	unit_model "forgejo.org/models/unit"
	"forgejo.org/models/unittest"
	user_model "forgejo.org/models/user"
	"forgejo.org/modules/setting"
	"forgejo.org/modules/util"
	files_service "forgejo.org/services/repository/files"
	"forgejo.org/tests"

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

func TestActionFetchTask_TaskCapacity(t *testing.T) {
	if !setting.Database.Type.IsSQLite3() {
		// mock repo runner only supported on SQLite testing
		t.Skip()
	}

	onApplicationRun(t, func(t *testing.T, u *url.URL) {
		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})

		// create the repo
		repo, _, f := tests.CreateDeclarativeRepo(t, user2, "repo-many-tasks",
			[]unit_model.Type{unit_model.TypeActions}, nil,
			[]*files_service.ChangeRepoFile{
				{
					Operation: "create",
					TreePath:  ".forgejo/workflows/matrix.yml",
					ContentReader: strings.NewReader(`
on:
  push:
jobs:
  job1:
    strategy:
      # matrix creates 125 different jobs from one push...
      matrix:
        d1: [a, b, c, d, e]
        d2: [a, b, c, d, e]
        d3: [a, b, c, d, e]
    runs-on: ubuntu-latest
    steps:
      - run: echo ${{ matrix.d1 }} ${{ matrix.d2 }} ${{ matrix.d3 }}
      - run: sleep 2
`),
				},
			},
		)
		defer f()

		runner := newMockRunner()
		runner.registerAsRepoRunner(t, user2.Name, repo.Name, "mock-runner", []string{"ubuntu-latest"})

		// Fetch with TaskCapacity undefined, set to nil, should return a single pending task
		task := runner.fetchTask(t)
		require.NotNil(t, task)
		assert.Contains(t, string(task.GetWorkflowPayload()), "name: job1 (a, a, a)")

		// After successfully fetching a task, the runner sets their next requested version to 0.  This allows it to
		// fetch back-to-back tasks without requiring that a server-side state change occurs.  That behaviour is
		// replicated here:
		runner.lastTasksVersion = 0

		// Fetch with TaskCapacity set to 1; additional should be nil
		capacity := int64(1)
		task, addt := runner.fetchMultipleTasks(t, &capacity)
		require.NotNil(t, task, "task")
		assert.Nil(t, addt, "addt")
		assert.Contains(t, string(task.GetWorkflowPayload()), "name: job1 (a, a, b)")

		runner.lastTasksVersion = 0

		capacity = 10
		task, addt = runner.fetchMultipleTasks(t, &capacity)
		require.NotNil(t, task, "task")
		require.NotNil(t, addt, "addt")
		assert.Contains(t, string(task.GetWorkflowPayload()), "name: job1 (a, a, c)")
		require.Len(t, addt, 9)
		assert.Contains(t, string(addt[0].GetWorkflowPayload()), "name: job1 (a, a, d)")
	})
}

func TestActionFetchTask_Idempotent(t *testing.T) {
	if !setting.Database.Type.IsSQLite3() {
		// mock repo runner only supported on SQLite testing
		t.Skip()
	}

	onApplicationRun(t, func(t *testing.T, u *url.URL) {
		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})

		// create the repo
		repo, _, f := tests.CreateDeclarativeRepo(t, user2, "repo-many-tasks",
			[]unit_model.Type{unit_model.TypeActions}, nil,
			[]*files_service.ChangeRepoFile{
				{
					Operation: "create",
					TreePath:  ".forgejo/workflows/matrix.yml",
					ContentReader: strings.NewReader(`
on:
  push:
jobs:
  job1:
    strategy:
      matrix:
        d1: [a, b]
    runs-on: ubuntu-latest
    steps:
      - run: sleep 2
`),
				},
			},
		)
		defer f()

		runner := newMockRunner()
		runner.registerAsRepoRunner(t, user2.Name, repo.Name, "mock-runner", []string{"ubuntu-latest"})

		runner.setRequestKey("4b518ff2-00c6-4c22-ba05-77d5b597c2b4")

		// First request that fetches a task:
		task1 := runner.fetchTask(t)
		require.NotNil(t, task1)
		assert.Contains(t, string(task1.GetWorkflowPayload()), "name: job1")
		{
			// Base assumption, the FORGEJO_TOKEN secret can be identified... this is typical but we'll verify that it
			// doesn't work after the idempotent fetch.
			taskTokenTest, err := actions_model.GetRunningTaskByToken(t.Context(), task1.Secrets["FORGEJO_TOKEN"])
			require.NoError(t, err)
			assert.Equal(t, task1.Id, taskTokenTest.ID)
		}

		// Having retrieved a task... if we sent a fetchTask call with the same requestKey then we expect to get the
		// same task again:
		task1fetchedAgain := runner.fetchTask(t)
		require.NotNil(t, task1fetchedAgain)
		assert.Contains(t, string(task1fetchedAgain.GetWorkflowPayload()), "name: job1")

		assert.Equal(t, task1.Id, task1fetchedAgain.Id)
		assert.Equal(t, task1.WorkflowPayload, task1fetchedAgain.WorkflowPayload)
		m1 := task1.Context.AsMap()
		m1fetchedAgain := task1fetchedAgain.Context.AsMap()
		for k, v1 := range m1 {
			v2 := m1fetchedAgain[k]
			// "token" isn't expected to be the same as it is regenerated on recovery from idempotent fetch.  But it is
			// expected to be present, so we test for equal length.  "gitea_runtime_token" is a signed JWT which can
			// change between invocations based upon precise timestamps used, and so similarly should be validated to be
			// present not necessarily identical.
			if k == "token" || k == "gitea_runtime_token" {
				assert.Len(t, v1.(string), len(v2.(string)))
			} else {
				assert.EqualValues(t, v1, v2, "context[%q]", k)
			}
		}
		for k, v1 := range task1.Secrets {
			v2 := task1fetchedAgain.Secrets[k]
			if k == "FORGEJO_TOKEN" || k == "GITEA_TOKEN" || k == "GITHUB_TOKEN" {
				// token isn't expected to be the same... but should be present.
				assert.Len(t, v1, len(v2))
			} else {
				assert.Equal(t, v1, v2, "secret[%q]", k)
			}
		}
		assert.Equal(t, task1.Needs, task1fetchedAgain.Needs)
		assert.Equal(t, task1.Vars, task1fetchedAgain.Vars)

		{
			// Original FORGEJO_TOKEN should not be usable anymore.
			_, err := actions_model.GetRunningTaskByToken(t.Context(), task1.Secrets["FORGEJO_TOKEN"])
			require.ErrorIs(t, err, util.ErrNotExist)
			// New FORGEJO_TOKEN should be usable.
			taskTokenTest, err := actions_model.GetRunningTaskByToken(t.Context(), task1fetchedAgain.Secrets["FORGEJO_TOKEN"])
			require.NoError(t, err)
			assert.Equal(t, task1fetchedAgain.Id, taskTokenTest.ID)
		}

		// But now if we change the request key, we don't expect to get the same task anymore:
		runner.setRequestKey("6d47d5f3-eaa2-449f-9040-8b20287401b3")
		task2 := runner.fetchTask(t)
		require.NotNil(t, task2)
		assert.NotEqual(t, task1.Id, task2.Id)
	})
}

func TestActionFetchTask_RequestedJob(t *testing.T) {
	if !setting.Database.Type.IsSQLite3() {
		// mock repo runner only supported on SQLite testing
		t.Skip()
	}

	onApplicationRun(t, func(t *testing.T, u *url.URL) {
		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})

		// create the repo
		repo, _, f := tests.CreateDeclarativeRepo(t, user2, "repo-many-tasks",
			[]unit_model.Type{unit_model.TypeActions}, nil,
			[]*files_service.ChangeRepoFile{
				{
					Operation: "create",
					TreePath:  ".forgejo/workflows/simple.yml",
					ContentReader: strings.NewReader(`
on:
  push:
jobs:
  job1:
    runs-on: ubuntu-latest
    steps:
      - run: echo OK
  job2:
    runs-on: debian
    steps:
      - run: echo OK
  job3:
    runs-on: debian
    steps:
      - run: echo OK
`),
				},
			},
		)
		defer f()

		debianRunner := newMockRunner()
		debianRunner.registerAsRepoRunner(t, user2.Name, repo.Name, "debian-runner", []string{"debian"})

		ubuntuRunner := newMockRunner()
		ubuntuRunner.registerAsRepoRunner(t, user2.Name, repo.Name, "ubuntu-runner", []string{"ubuntu-latest"})

		job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RepoID: repo.ID, Name: "job1"})
		job2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RepoID: repo.ID, Name: "job2"})
		job3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RepoID: repo.ID, Name: "job3"})

		assert.NotEmpty(t, job1.Handle)
		assert.NotEmpty(t, job2.Handle)
		assert.NotEmpty(t, job3.Handle)

		nonExistingHandle := "does-not-exist"
		emptyHandle := ""

		// The runner's labels do not match. Therefore, it does not receive the job despite explicitly asking for it.
		task := debianRunner.maybeFetchSingleTask(t, &job1.Handle)
		require.Nil(t, task)

		// If the requested job does not exist or is not ready, the runner does not receive any job.
		task = ubuntuRunner.maybeFetchSingleTask(t, &nonExistingHandle)
		require.Nil(t, task)

		ubuntuRunner.lastTasksVersion = 0
		debianRunner.lastTasksVersion = 0

		// The next job waiting in line for the debian-runner is job2. But because the runner explicitly asks for job3,
		// it receives job3 instead.
		task = debianRunner.maybeFetchSingleTask(t, &job3.Handle)
		require.NotNil(t, task)
		assert.Contains(t, string(task.GetWorkflowPayload()), "name: job3")

		ubuntuRunner.lastTasksVersion = 0
		debianRunner.lastTasksVersion = 0

		// Without explicitly asking for a job, the runners receives the next job waiting in line.
		task = debianRunner.maybeFetchSingleTask(t, nil)
		require.NotNil(t, task)
		assert.Contains(t, string(task.GetWorkflowPayload()), "name: job2")

		task = ubuntuRunner.maybeFetchSingleTask(t, &emptyHandle)
		require.NotNil(t, task)
		assert.Contains(t, string(task.GetWorkflowPayload()), "name: job1")
	})
}
