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

package integration

import (
	"context"
	"encoding/hex"
	"fmt"
	"io"
	mathRand "math/rand/v2"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"testing"
	"time"

	auth_model "code.gitea.io/gitea/models/auth"
	issues_model "code.gitea.io/gitea/models/issues"
	"code.gitea.io/gitea/models/perm"
	repo_model "code.gitea.io/gitea/models/repo"
	"code.gitea.io/gitea/models/unittest"
	user_model "code.gitea.io/gitea/models/user"
	"code.gitea.io/gitea/modules/commitstatus"
	"code.gitea.io/gitea/modules/git"
	"code.gitea.io/gitea/modules/git/gitcmd"
	"code.gitea.io/gitea/modules/lfs"
	"code.gitea.io/gitea/modules/setting"
	api "code.gitea.io/gitea/modules/structs"
	"code.gitea.io/gitea/tests"

	"github.com/kballard/go-shellquote"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

const (
	testFileSizeSmall = 10
	testFileSizeLarge = 10 * 1024 * 1024 // 10M
)

func TestGitGeneral(t *testing.T) {
	onGiteaRun(t, testGitGeneral)
}

func testGitGeneral(t *testing.T, u *url.URL) {
	username := "user2"
	baseAPITestContext := NewAPITestContext(t, username, "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)

	u.Path = baseAPITestContext.GitPath()

	forkedUserCtx := NewAPITestContext(t, "user4", "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)

	t.Run("HTTP", func(t *testing.T) {
		defer tests.PrintCurrentTest(t)()
		ensureAnonymousClone(t, u)
		httpContext := baseAPITestContext
		httpContext.Reponame = "repo-tmp-17"
		forkedUserCtx.Reponame = httpContext.Reponame

		dstPath := t.TempDir()

		t.Run("CreateRepoInDifferentUser", doAPICreateRepository(forkedUserCtx, false))
		t.Run("AddUserAsCollaborator", doAPIAddCollaborator(forkedUserCtx, httpContext.Username, perm.AccessModeRead))

		t.Run("ForkFromDifferentUser", doAPIForkRepository(httpContext, forkedUserCtx.Username))

		u.Path = httpContext.GitPath()
		u.User = url.UserPassword(username, userPassword)

		t.Run("Clone", doGitClone(dstPath, u))

		dstPath2 := t.TempDir()

		t.Run("Partial Clone", doPartialGitClone(dstPath2, u))

		pushedFilesStandard := standardCommitAndPushTest(t, dstPath, testFileSizeSmall, testFileSizeLarge)
		pushedFilesLFS := lfsCommitAndPushTest(t, dstPath, testFileSizeSmall, testFileSizeLarge)
		rawTest(t, &httpContext, pushedFilesStandard[0], pushedFilesStandard[1], pushedFilesLFS[0], pushedFilesLFS[1])
		mediaTest(t, &httpContext, pushedFilesStandard[0], pushedFilesStandard[1], pushedFilesLFS[0], pushedFilesLFS[1])

		t.Run("CreateAgitFlowPull", doCreateAgitFlowPull(dstPath, &httpContext, "test/head"))
		t.Run("CreateProtectedBranch", doCreateProtectedBranch(&httpContext, dstPath))
		t.Run("BranchProtectMerge", doBranchProtectPRMerge(&httpContext, dstPath))
		t.Run("AutoMerge", doAutoPRMerge(&httpContext, dstPath))
		t.Run("CreatePRAndSetManuallyMerged", doCreatePRAndSetManuallyMerged(httpContext, httpContext, dstPath, "master", "test-manually-merge"))
		t.Run("MergeFork", func(t *testing.T) {
			defer tests.PrintCurrentTest(t)()
			t.Run("CreatePRAndMerge", doMergeFork(httpContext, forkedUserCtx, "master", httpContext.Username+":master"))
			rawTest(t, &forkedUserCtx, pushedFilesStandard[0], pushedFilesStandard[1], pushedFilesLFS[0], pushedFilesLFS[1])
			mediaTest(t, &forkedUserCtx, pushedFilesStandard[0], pushedFilesStandard[1], pushedFilesLFS[0], pushedFilesLFS[1])
		})

		t.Run("PushCreate", doPushCreate(httpContext, u))
	})
	t.Run("SSH", func(t *testing.T) {
		defer tests.PrintCurrentTest(t)()
		sshContext := baseAPITestContext
		sshContext.Reponame = "repo-tmp-18"
		keyname := "my-testing-key"
		forkedUserCtx.Reponame = sshContext.Reponame
		t.Run("CreateRepoInDifferentUser", doAPICreateRepository(forkedUserCtx, false))
		t.Run("AddUserAsCollaborator", doAPIAddCollaborator(forkedUserCtx, sshContext.Username, perm.AccessModeRead))
		t.Run("ForkFromDifferentUser", doAPIForkRepository(sshContext, forkedUserCtx.Username))

		// Setup key the user ssh key
		withKeyFile(t, keyname, func(keyFile string) {
			var keyID int64
			t.Run("CreateUserKey", doAPICreateUserKey(sshContext, "test-key", keyFile, func(t *testing.T, key api.PublicKey) {
				keyID = key.ID
			}))
			assert.NotZero(t, keyID)
			t.Run("LFSAccessTest", doSSHLFSAccessTest(sshContext, keyID))

			// Setup remote link
			// TODO: get url from api
			sshURL := createSSHUrl(sshContext.GitPath(), u)

			// Setup clone folder
			dstPath := t.TempDir()

			t.Run("Clone", doGitClone(dstPath, sshURL))

			pushedFilesStandard := standardCommitAndPushTest(t, dstPath, testFileSizeSmall, testFileSizeLarge)
			pushedFilesLFS := lfsCommitAndPushTest(t, dstPath, testFileSizeSmall, testFileSizeLarge)
			rawTest(t, &sshContext, pushedFilesStandard[0], pushedFilesStandard[1], pushedFilesLFS[0], pushedFilesLFS[1])
			mediaTest(t, &sshContext, pushedFilesStandard[0], pushedFilesStandard[1], pushedFilesLFS[0], pushedFilesLFS[1])

			t.Run("CreateAgitFlowPull", doCreateAgitFlowPull(dstPath, &sshContext, "test/head2"))
			t.Run("CreateProtectedBranch", doCreateProtectedBranch(&sshContext, dstPath))
			t.Run("BranchProtectMerge", doBranchProtectPRMerge(&sshContext, dstPath))
			t.Run("MergeFork", func(t *testing.T) {
				defer tests.PrintCurrentTest(t)()
				t.Run("CreatePRAndMerge", doMergeFork(sshContext, forkedUserCtx, "master", sshContext.Username+":master"))
				rawTest(t, &forkedUserCtx, pushedFilesStandard[0], pushedFilesStandard[1], pushedFilesLFS[0], pushedFilesLFS[1])
				mediaTest(t, &forkedUserCtx, pushedFilesStandard[0], pushedFilesStandard[1], pushedFilesLFS[0], pushedFilesLFS[1])
			})

			t.Run("PushCreate", doPushCreate(sshContext, sshURL))
		})
	})
}

func doSSHLFSAccessTest(_ APITestContext, keyID int64) func(*testing.T) {
	return func(t *testing.T) {
		sshCommand := os.Getenv("GIT_SSH_COMMAND")       // it is set in withKeyFile
		sshCmdParts, err := shellquote.Split(sshCommand) // and parse the ssh command to construct some mocked arguments
		require.NoError(t, err)

		t.Run("User2AccessOwned", func(t *testing.T) {
			sshCmdUser2Self := append(slices.Clone(sshCmdParts),
				"-p", strconv.Itoa(setting.SSH.ListenPort), "git@"+setting.SSH.ListenHost,
				"git-lfs-authenticate", "user2/repo1.git", "upload", // accessible to own repo
			)
			cmd := exec.CommandContext(t.Context(), sshCmdUser2Self[0], sshCmdUser2Self[1:]...)
			_, err := cmd.Output()
			assert.NoError(t, err) // accessible, no error
		})

		t.Run("User2AccessOther", func(t *testing.T) {
			sshCmdUser2Other := append(slices.Clone(sshCmdParts),
				"-p", strconv.Itoa(setting.SSH.ListenPort), "git@"+setting.SSH.ListenHost,
				"git-lfs-authenticate", "user5/repo4.git", "upload", // inaccessible to other's (user5/repo4)
			)
			cmd := exec.CommandContext(t.Context(), sshCmdUser2Other[0], sshCmdUser2Other[1:]...)
			_, err := cmd.Output()
			var errExit *exec.ExitError
			require.ErrorAs(t, err, &errExit) // inaccessible, error
			assert.Contains(t, string(errExit.Stderr), fmt.Sprintf("User: 2:user2 with Key: %d:test-key is not authorized to write to user5/repo4.", keyID))
		})
	}
}

func ensureAnonymousClone(t *testing.T, u *url.URL) {
	dstLocalPath := t.TempDir()
	t.Run("CloneAnonymous", doGitClone(dstLocalPath, u))
}

func standardCommitAndPushTest(t *testing.T, dstPath string, sizes ...int) (pushedFiles []string) {
	t.Run("CommitAndPushStandard", func(t *testing.T) {
		defer tests.PrintCurrentTest(t)()
		pushedFiles = commitAndPushTest(t, dstPath, "data-file-", sizes...)
	})
	return pushedFiles
}

func lfsCommitAndPushTest(t *testing.T, dstPath string, sizes ...int) (pushedFiles []string) {
	t.Run("CommitAndPushLFS", func(t *testing.T) {
		defer tests.PrintCurrentTest(t)()
		prefix := "lfs-data-file-"
		err := gitcmd.NewCommand("lfs").AddArguments("install").WithDir(dstPath).Run(t.Context())
		assert.NoError(t, err)
		_, _, err = gitcmd.NewCommand("lfs").AddArguments("track").AddDynamicArguments(prefix + "*").
			WithDir(dstPath).RunStdString(t.Context())
		assert.NoError(t, err)
		err = gitAddChangesDeprecated(t.Context(), dstPath, false, ".gitattributes")
		assert.NoError(t, err)

		err = gitCommitChangesDeprecated(t.Context(), dstPath, gitCommitChangesOptions{
			Committer: &git.Signature{
				Email: "user2@example.com",
				Name:  "User Two",
				When:  time.Now(),
			},
			Author: &git.Signature{
				Email: "user2@example.com",
				Name:  "User Two",
				When:  time.Now(),
			},
			Message: fmt.Sprintf("Testing commit @ %v", time.Now()),
		})
		assert.NoError(t, err)

		pushedFiles = commitAndPushTest(t, dstPath, prefix, sizes...)
		t.Run("Locks", func(t *testing.T) {
			defer tests.PrintCurrentTest(t)()
			lockTest(t, dstPath)
		})
	})
	return pushedFiles
}

func commitAndPushTest(t *testing.T, dstPath, prefix string, sizes ...int) (pushedFiles []string) {
	for _, size := range sizes {
		t.Run("PushCommit Size-"+strconv.Itoa(size), func(t *testing.T) {
			defer tests.PrintCurrentTest(t)()
			pushedFiles = append(pushedFiles, doCommitAndPush(t, size, dstPath, prefix))
		})
	}
	return pushedFiles
}

func rawTest(t *testing.T, ctx *APITestContext, little, big, littleLFS, bigLFS string) {
	t.Run("Raw", func(t *testing.T) {
		defer tests.PrintCurrentTest(t)()
		username := ctx.Username
		reponame := ctx.Reponame

		session := loginUser(t, username)

		// Request raw paths
		req := NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", little))
		resp := session.MakeRequestNilResponseRecorder(t, req, http.StatusOK)
		assert.Equal(t, testFileSizeSmall, resp.Length)

		if setting.LFS.StartServer {
			req = NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", littleLFS))
			resp := session.MakeRequest(t, req, http.StatusOK)
			assert.NotEqual(t, testFileSizeSmall, resp.Body.Len())
			assert.LessOrEqual(t, resp.Body.Len(), 1024)
			if resp.Body.Len() != testFileSizeSmall && resp.Body.Len() <= 1024 {
				assert.Contains(t, resp.Body.String(), lfs.MetaFileIdentifier)
			}
		}

		if !testing.Short() {
			req = NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", big))
			resp := session.MakeRequestNilResponseRecorder(t, req, http.StatusOK)
			assert.Equal(t, testFileSizeLarge, resp.Length)

			if setting.LFS.StartServer {
				req = NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", bigLFS))
				resp := session.MakeRequest(t, req, http.StatusOK)
				assert.NotEqual(t, testFileSizeLarge, resp.Body.Len())
				if resp.Body.Len() != testFileSizeLarge && resp.Body.Len() <= 1024 {
					assert.Contains(t, resp.Body.String(), lfs.MetaFileIdentifier)
				}
			}
		}
	})
}

func mediaTest(t *testing.T, ctx *APITestContext, little, big, littleLFS, bigLFS string) {
	t.Run("Media", func(t *testing.T) {
		defer tests.PrintCurrentTest(t)()

		username := ctx.Username
		reponame := ctx.Reponame

		session := loginUser(t, username)

		// Request media paths
		req := NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", little))
		resp := session.MakeRequestNilResponseRecorder(t, req, http.StatusOK)
		assert.Equal(t, testFileSizeSmall, resp.Length)

		req = NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", littleLFS))
		resp = session.MakeRequestNilResponseRecorder(t, req, http.StatusOK)
		assert.Equal(t, testFileSizeSmall, resp.Length)

		if !testing.Short() {
			req = NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", big))
			resp = session.MakeRequestNilResponseRecorder(t, req, http.StatusOK)
			assert.Equal(t, testFileSizeLarge, resp.Length)

			if setting.LFS.StartServer {
				req = NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", bigLFS))
				resp = session.MakeRequestNilResponseRecorder(t, req, http.StatusOK)
				assert.Equal(t, testFileSizeLarge, resp.Length)
			}
		}
	})
}

func lockTest(t *testing.T, repoPath string) {
	lockFileTest(t, "README.md", repoPath)
}

func lockFileTest(t *testing.T, filename, repoPath string) {
	_, _, err := gitcmd.NewCommand("lfs").AddArguments("locks").WithDir(repoPath).RunStdString(t.Context())
	assert.NoError(t, err)
	_, _, err = gitcmd.NewCommand("lfs").AddArguments("lock").AddDynamicArguments(filename).WithDir(repoPath).RunStdString(t.Context())
	assert.NoError(t, err)
	_, _, err = gitcmd.NewCommand("lfs").AddArguments("locks").WithDir(repoPath).RunStdString(t.Context())
	assert.NoError(t, err)
	_, _, err = gitcmd.NewCommand("lfs").AddArguments("unlock").AddDynamicArguments(filename).WithDir(repoPath).RunStdString(t.Context())
	assert.NoError(t, err)
}

func doCommitAndPush(t *testing.T, size int, repoPath, prefix string) string {
	name, err := generateCommitWithNewData(t.Context(), size, repoPath, "user2@example.com", "User Two", prefix)
	assert.NoError(t, err)
	_, _, err = gitcmd.NewCommand("push", "origin", "master").WithDir(repoPath).RunStdString(t.Context())
	assert.NoError(t, err)
	return name
}

func generateCommitWithNewData(ctx context.Context, size int, repoPath, email, fullName, prefix string) (string, error) {
	tmpFile, err := os.CreateTemp(repoPath, prefix)
	if err != nil {
		return "", err
	}
	defer tmpFile.Close()

	var seed [32]byte
	rander := mathRand.NewChaCha8(seed) // for testing only, no need to seed
	_, err = io.CopyN(tmpFile, rander, int64(size))
	if err != nil {
		return "", err
	}
	_ = tmpFile.Close()

	// Commit
	err = gitAddChangesDeprecated(ctx, repoPath, false, filepath.Base(tmpFile.Name()))
	if err != nil {
		return "", err
	}
	err = gitCommitChangesDeprecated(ctx, repoPath, gitCommitChangesOptions{
		Committer: &git.Signature{
			Email: email,
			Name:  fullName,
			When:  time.Now(),
		},
		Author: &git.Signature{
			Email: email,
			Name:  fullName,
			When:  time.Now(),
		},
		Message: fmt.Sprintf("Testing commit @ %v", time.Now()),
	})
	return filepath.Base(tmpFile.Name()), err
}

func doCreateProtectedBranch(baseCtx *APITestContext, dstPath string) func(t *testing.T) {
	return func(t *testing.T) {
		defer tests.PrintCurrentTest(t)()
		ctx := NewAPITestContext(t, baseCtx.Username, baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository)

		t.Run("ProtectBranchWithFilePatterns", doProtectBranch(ctx, "release-*", baseCtx.Username, "", "", "config*"))

		// push a new branch without any new commits
		t.Run("CreateProtectedBranch-NoChanges", doGitCreateBranch(dstPath, "release-v1.0"))
		t.Run("PushProtectedBranch-NoChanges", doGitPushTestRepository(dstPath, "origin", "release-v1.0"))
		t.Run("CheckoutMaster-NoChanges", doGitCheckoutBranch(dstPath, "master"))

		// push a new branch with a new unprotected file
		t.Run("CreateProtectedBranch-UnprotectedFile", doGitCreateBranch(dstPath, "release-v2.0"))
		_, err := generateCommitWithNewData(t.Context(), testFileSizeSmall, dstPath, "user2@example.com", "User Two", "abc.txt")
		assert.NoError(t, err)
		t.Run("PushProtectedBranch-UnprotectedFile", doGitPushTestRepository(dstPath, "origin", "release-v2.0"))
		t.Run("CheckoutMaster-UnprotectedFile", doGitCheckoutBranch(dstPath, "master"))

		// push a new branch with a new protected file
		t.Run("CreateProtectedBranch-ProtectedFile", doGitCreateBranch(dstPath, "release-v3.0"))
		_, err = generateCommitWithNewData(t.Context(), testFileSizeSmall, dstPath, "user2@example.com", "User Two", "config")
		assert.NoError(t, err)
		t.Run("PushProtectedBranch-ProtectedFile", doGitPushTestRepositoryFail(dstPath, "origin", "release-v3.0"))
		t.Run("CheckoutMaster-ProtectedFile", doGitCheckoutBranch(dstPath, "master"))
	}
}

func doBranchProtectPRMerge(baseCtx *APITestContext, dstPath string) func(t *testing.T) {
	return func(t *testing.T) {
		defer tests.PrintCurrentTest(t)()
		t.Run("CreateBranchProtected", doGitCreateBranch(dstPath, "protected"))
		t.Run("PushProtectedBranch", doGitPushTestRepository(dstPath, "origin", "protected"))

		ctx := NewAPITestContext(t, baseCtx.Username, baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository)

		// Protect branch without any whitelisting
		t.Run("ProtectBranchNoWhitelist", doProtectBranch(ctx, "protected", "", "", "", ""))

		// Try to push without permissions, which should fail
		t.Run("TryPushWithoutPermissions", func(t *testing.T) {
			_, err := generateCommitWithNewData(t.Context(), testFileSizeSmall, dstPath, "user2@example.com", "User Two", "branch-data-file-")
			assert.NoError(t, err)
			doGitPushTestRepositoryFail(dstPath, "origin", "protected")(t)
		})

		// Set up permissions for normal push but not force push
		t.Run("SetupNormalPushPermissions", doProtectBranch(ctx, "protected", baseCtx.Username, "", "", ""))

		// Normal push should work
		t.Run("NormalPushWithPermissions", func(t *testing.T) {
			_, err := generateCommitWithNewData(t.Context(), testFileSizeSmall, dstPath, "user2@example.com", "User Two", "branch-data-file-")
			assert.NoError(t, err)
			doGitPushTestRepository(dstPath, "origin", "protected")(t)
		})

		// Try to force push without force push permissions, which should fail
		t.Run("ForcePushWithoutForcePermissions", func(t *testing.T) {
			t.Run("CreateDivergentHistory", func(t *testing.T) {
				gitcmd.NewCommand("reset", "--hard", "HEAD~1").WithDir(dstPath).Run(t.Context())
				_, err := generateCommitWithNewData(t.Context(), testFileSizeSmall, dstPath, "user2@example.com", "User Two", "branch-data-file-new")
				assert.NoError(t, err)
			})
			doGitPushTestRepositoryFail(dstPath, "-f", "origin", "protected")(t)
		})

		// Set up permissions for force push but not normal push
		t.Run("SetupForcePushPermissions", doProtectBranch(ctx, "protected", "", baseCtx.Username, "", ""))

		// Try to force push without normal push permissions, which should fail
		t.Run("ForcePushWithoutNormalPermissions", doGitPushTestRepositoryFail(dstPath, "-f", "origin", "protected"))

		// Set up permissions for normal and force push (both are required to force push)
		t.Run("SetupNormalAndForcePushPermissions", doProtectBranch(ctx, "protected", baseCtx.Username, baseCtx.Username, "", ""))

		// Force push should now work
		t.Run("ForcePushWithPermissions", doGitPushTestRepository(dstPath, "-f", "origin", "protected"))

		t.Run("ProtectProtectedBranchNoWhitelist", doProtectBranch(ctx, "protected", "", "", "", ""))
		t.Run("PushToUnprotectedBranch", doGitPushTestRepository(dstPath, "origin", "protected:unprotected"))
		var pr api.PullRequest
		var err error
		t.Run("CreatePullRequest", func(t *testing.T) {
			pr, err = doAPICreatePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, "protected", "unprotected")(t)
			assert.NoError(t, err)
		})
		t.Run("GenerateCommit", func(t *testing.T) {
			_, err := generateCommitWithNewData(t.Context(), testFileSizeSmall, dstPath, "user2@example.com", "User Two", "branch-data-file-")
			assert.NoError(t, err)
		})
		t.Run("PushToUnprotectedBranch", doGitPushTestRepository(dstPath, "origin", "protected:unprotected-2"))
		var pr2 api.PullRequest
		t.Run("CreatePullRequest", func(t *testing.T) {
			pr2, err = doAPICreatePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, "unprotected", "unprotected-2")(t)
			assert.NoError(t, err)
		})
		t.Run("MergePR2", doAPIMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr2.Index))
		t.Run("MergePR", doAPIMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))
		t.Run("PullProtected", doGitPull(dstPath, "origin", "protected"))

		t.Run("ProtectProtectedBranchUnprotectedFilePaths", doProtectBranch(ctx, "protected", "", "", "unprotected-file-*", ""))
		t.Run("GenerateCommit", func(t *testing.T) {
			_, err := generateCommitWithNewData(t.Context(), testFileSizeSmall, dstPath, "user2@example.com", "User Two", "unprotected-file-")
			assert.NoError(t, err)
		})
		t.Run("PushUnprotectedFilesToProtectedBranch", doGitPushTestRepository(dstPath, "origin", "protected"))

		t.Run("ProtectProtectedBranchWhitelist", doProtectBranch(ctx, "protected", baseCtx.Username, "", "", ""))

		t.Run("CheckoutMaster", doGitCheckoutBranch(dstPath, "master"))
		t.Run("CreateBranchForced", doGitCreateBranch(dstPath, "toforce"))
		t.Run("GenerateCommit", func(t *testing.T) {
			_, err := generateCommitWithNewData(t.Context(), testFileSizeSmall, dstPath, "user2@example.com", "User Two", "branch-data-file-")
			assert.NoError(t, err)
		})
		t.Run("FailToForcePushToProtectedBranch", doGitPushTestRepositoryFail(dstPath, "-f", "origin", "toforce:protected"))
		t.Run("MergeProtectedToToforce", doGitMerge(dstPath, "protected"))
		t.Run("PushToProtectedBranch", doGitPushTestRepository(dstPath, "origin", "toforce:protected"))
		t.Run("CheckoutMasterAgain", doGitCheckoutBranch(dstPath, "master"))
	}
}

func doProtectBranch(ctx APITestContext, branch, userToWhitelistPush, userToWhitelistForcePush, unprotectedFilePatterns, protectedFilePatterns string) func(t *testing.T) {
	return doProtectBranchExt(ctx, branch, doProtectBranchOptions{
		UserToWhitelistPush:      userToWhitelistPush,
		UserToWhitelistForcePush: userToWhitelistForcePush,
		UnprotectedFilePatterns:  unprotectedFilePatterns,
		ProtectedFilePatterns:    protectedFilePatterns,
	})
}

type doProtectBranchOptions struct {
	UserToWhitelistPush, UserToWhitelistForcePush, UnprotectedFilePatterns, ProtectedFilePatterns string

	StatusCheckPatterns []string
}

func doProtectBranchExt(ctx APITestContext, ruleName string, opts doProtectBranchOptions) func(t *testing.T) {
	// We are going to just use the owner to set the protection.
	return func(t *testing.T) {
		formData := map[string]string{
			"rule_name":                 ruleName,
			"unprotected_file_patterns": opts.UnprotectedFilePatterns,
			"protected_file_patterns":   opts.ProtectedFilePatterns,
		}

		if opts.UserToWhitelistPush != "" {
			user, err := user_model.GetUserByName(t.Context(), opts.UserToWhitelistPush)
			assert.NoError(t, err)
			formData["whitelist_users"] = strconv.FormatInt(user.ID, 10)
			formData["enable_push"] = "whitelist"
			formData["enable_whitelist"] = "on"
		}

		if opts.UserToWhitelistForcePush != "" {
			user, err := user_model.GetUserByName(t.Context(), opts.UserToWhitelistForcePush)
			assert.NoError(t, err)
			formData["force_push_allowlist_users"] = strconv.FormatInt(user.ID, 10)
			formData["enable_force_push"] = "whitelist"
			formData["enable_force_push_allowlist"] = "on"
		}

		if len(opts.StatusCheckPatterns) > 0 {
			formData["enable_status_check"] = "on"
			formData["status_check_contexts"] = strings.Join(opts.StatusCheckPatterns, "\n")
		}

		// Send the request to update branch protection settings
		req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), formData)
		ctx.Session.MakeRequest(t, req, http.StatusSeeOther)

		// Check if the "master" branch has been locked successfully
		flashMsg := ctx.Session.GetCookieFlashMessage()
		assert.Equal(t, `Branch protection for rule "`+ruleName+`" has been updated.`, flashMsg.SuccessMsg)
	}
}

func doMergeFork(ctx, baseCtx APITestContext, baseBranch, headBranch string) func(t *testing.T) {
	return func(t *testing.T) {
		defer tests.PrintCurrentTest(t)()
		var pr api.PullRequest
		var err error

		// Create a test pullrequest
		t.Run("CreatePullRequest", func(t *testing.T) {
			pr, err = doAPICreatePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, baseBranch, headBranch)(t)
			assert.NoError(t, err)
		})

		// Ensure the PR page works
		t.Run("EnsureCanSeePull", doEnsureCanSeePull(baseCtx, pr))

		// Then get the diff string
		var diffHash string
		var diffLength int
		t.Run("GetDiff", func(t *testing.T) {
			req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d.diff", url.PathEscape(baseCtx.Username), url.PathEscape(baseCtx.Reponame), pr.Index))
			resp := ctx.Session.MakeRequestNilResponseHashSumRecorder(t, req, http.StatusOK)
			diffHash = string(resp.Hash.Sum(nil))
			diffLength = resp.Length
		})

		// Now: Merge the PR & make sure that doesn't break the PR page or change its diff
		t.Run("MergePR", doAPIMergePullRequest(baseCtx, baseCtx.Username, baseCtx.Reponame, pr.Index))
		t.Run("EnsureCanSeePull", doEnsureCanSeePull(baseCtx, pr))
		t.Run("CheckPR", func(t *testing.T) {
			oldMergeBase := pr.MergeBase
			pr2, err := doAPIGetPullRequest(baseCtx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t)
			assert.NoError(t, err)
			assert.Equal(t, oldMergeBase, pr2.MergeBase)
		})
		t.Run("EnsurDiffNoChange", doEnsureDiffNoChange(baseCtx, pr, diffHash, diffLength))

		// Then: Delete the head branch & make sure that doesn't break the PR page or change its diff
		t.Run("DeleteHeadBranch", doBranchDelete(baseCtx, baseCtx.Username, baseCtx.Reponame, headBranch))
		t.Run("EnsureCanSeePull", doEnsureCanSeePull(baseCtx, pr))
		t.Run("EnsureDiffNoChange", doEnsureDiffNoChange(baseCtx, pr, diffHash, diffLength))

		// Delete the head repository & make sure that doesn't break the PR page or change its diff
		t.Run("DeleteHeadRepository", doAPIDeleteRepository(ctx))
		t.Run("EnsureCanSeePull", doEnsureCanSeePull(baseCtx, pr))
		t.Run("EnsureDiffNoChange", doEnsureDiffNoChange(baseCtx, pr, diffHash, diffLength))
	}
}

func doCreatePRAndSetManuallyMerged(ctx, baseCtx APITestContext, dstPath, baseBranch, headBranch string) func(t *testing.T) {
	return func(t *testing.T) {
		defer tests.PrintCurrentTest(t)()
		var (
			pr           api.PullRequest
			err          error
			lastCommitID string
		)

		trueBool := true
		falseBool := false

		t.Run("AllowSetManuallyMergedAndSwitchOffAutodetectManualMerge", doAPIEditRepository(baseCtx, &api.EditRepoOption{
			HasPullRequests:       &trueBool,
			AllowManualMerge:      &trueBool,
			AutodetectManualMerge: &falseBool,
		}))

		t.Run("CreateHeadBranch", doGitCreateBranch(dstPath, headBranch))
		t.Run("PushToHeadBranch", doGitPushTestRepository(dstPath, "origin", headBranch))
		t.Run("CreateEmptyPullRequest", func(t *testing.T) {
			pr, err = doAPICreatePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, baseBranch, headBranch)(t)
			assert.NoError(t, err)
		})
		lastCommitID = pr.Base.Sha
		t.Run("ManuallyMergePR", doAPIManuallyMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, lastCommitID, pr.Index))
	}
}

func doEnsureCanSeePull(ctx APITestContext, pr api.PullRequest) func(t *testing.T) {
	return func(t *testing.T) {
		req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr.Index))
		ctx.Session.MakeRequest(t, req, http.StatusOK)
		req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d/files", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr.Index))
		ctx.Session.MakeRequest(t, req, http.StatusOK)
		req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d/commits", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr.Index))
		ctx.Session.MakeRequest(t, req, http.StatusOK)
	}
}

func doEnsureDiffNoChange(ctx APITestContext, pr api.PullRequest, diffHash string, diffLength int) func(t *testing.T) {
	return func(t *testing.T) {
		req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d.diff", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr.Index))
		resp := ctx.Session.MakeRequestNilResponseHashSumRecorder(t, req, http.StatusOK)
		actual := string(resp.Hash.Sum(nil))
		actualLength := resp.Length

		equal := diffHash == actual
		assert.True(t, equal, "Unexpected change in the diff string: expected hash: %s size: %d but was actually: %s size: %d", hex.EncodeToString([]byte(diffHash)), diffLength, hex.EncodeToString([]byte(actual)), actualLength)
	}
}

func doPushCreate(ctx APITestContext, u *url.URL) func(t *testing.T) {
	return func(t *testing.T) {
		defer tests.PrintCurrentTest(t)()

		// create a context for a currently non-existent repository
		ctx.Reponame = "repo-tmp-push-create-" + u.Scheme
		u.Path = ctx.GitPath()

		// Create a temporary directory
		tmpDir := t.TempDir()

		// Now create local repository to push as our test and set its origin
		t.Run("InitTestRepository", doGitInitTestRepository(tmpDir))
		t.Run("AddRemote", doGitAddRemote(tmpDir, "origin", u))

		// Disable "Push To Create" and attempt to push
		setting.Repository.EnablePushCreateUser = false
		t.Run("FailToPushAndCreateTestRepository", doGitPushTestRepositoryFail(tmpDir, "origin", "master"))

		// Enable "Push To Create"
		setting.Repository.EnablePushCreateUser = true

		// Assert that cloning from a non-existent repository does not create it and that it definitely wasn't create above
		t.Run("FailToCloneFromNonExistentRepository", doGitCloneFail(u))

		// Then "Push To Create"x
		t.Run("SuccessfullyPushAndCreateTestRepository", doGitPushTestRepository(tmpDir, "origin", "master"))

		// Finally, fetch repo from database and ensure the correct repository has been created
		repo, err := repo_model.GetRepositoryByOwnerAndName(t.Context(), ctx.Username, ctx.Reponame)
		assert.NoError(t, err)
		assert.False(t, repo.IsEmpty)
		assert.True(t, repo.IsPrivate)

		// Now add a remote that is invalid to "Push To Create"
		invalidCtx := ctx
		invalidCtx.Reponame = "invalid/repo-tmp-push-create-" + u.Scheme
		u.Path = invalidCtx.GitPath()
		t.Run("AddInvalidRemote", doGitAddRemote(tmpDir, "invalid", u))

		// Fail to "Push To Create" the invalid
		t.Run("FailToPushAndCreateInvalidTestRepository", doGitPushTestRepositoryFail(tmpDir, "invalid", "master"))
	}
}

func doBranchDelete(ctx APITestContext, owner, repo, branch string) func(*testing.T) {
	return func(t *testing.T) {
		req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/branches/delete?name=%s", url.PathEscape(owner), url.PathEscape(repo), url.QueryEscape(branch)))
		ctx.Session.MakeRequest(t, req, http.StatusOK)
	}
}

func doAutoPRMerge(baseCtx *APITestContext, dstPath string) func(t *testing.T) {
	return func(t *testing.T) {
		defer tests.PrintCurrentTest(t)()

		ctx := NewAPITestContext(t, baseCtx.Username, baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository)
		collaboratorCtx := NewAPITestContext(t, "user5", baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository)
		readOnlyCtx := NewAPITestContext(t, "user4", baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository)

		t.Run("AddAutoMergeCollaborator", doAPIAddCollaborator(*baseCtx, collaboratorCtx.Username, perm.AccessModeWrite))
		t.Run("AddReadOnlyAutoMergeCollaborator", doAPIAddCollaborator(*baseCtx, readOnlyCtx.Username, perm.AccessModeRead))

		// automerge will merge immediately if the PR is mergeable and there is no "status check" because no status check also means "all checks passed"
		// so we must set up a status check to test the auto merge feature
		doProtectBranchExt(ctx, "protected", doProtectBranchOptions{StatusCheckPatterns: []string{"*"}})(t)

		t.Run("CheckoutProtected", doGitCheckoutBranch(dstPath, "protected"))
		t.Run("PullProtected", doGitPull(dstPath, "origin", "protected"))
		t.Run("GenerateCommit", func(t *testing.T) {
			_, err := generateCommitWithNewData(t.Context(), testFileSizeSmall, dstPath, "user2@example.com", "User Two", "branch-data-file-")
			assert.NoError(t, err)
		})
		t.Run("PushToUnprotectedBranch", doGitPushTestRepository(dstPath, "origin", "protected:unprotected3"))
		var pr api.PullRequest
		var err error
		t.Run("CreatePullRequest", func(t *testing.T) {
			pr, err = doAPICreatePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, "protected", "unprotected3")(t)
			assert.NoError(t, err)
		})

		// Request repository commits page
		req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d/commits", baseCtx.Username, baseCtx.Reponame, pr.Index))
		resp := ctx.Session.MakeRequest(t, req, http.StatusOK)
		doc := NewHTMLParser(t, resp.Body)

		// Get first commit URL
		commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Last().Attr("href")
		assert.True(t, exists)
		assert.NotEmpty(t, commitURL)

		commitID := path.Base(commitURL)

		// Call API to add Pending status for commit
		t.Run("CreateStatus", doAPICreateCommitStatusTest(ctx, commitID, commitstatus.CommitStatusPending, "testci"))

		// Cancel not existing auto merge
		ctx.ExpectedCode = http.StatusNotFound
		t.Run("CancelAutoMergePRNotExist", doAPICancelAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))

		// Add auto merge request
		ctx.ExpectedCode = http.StatusCreated
		t.Run("AutoMergePR", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))

		// Cannot create schedule twice
		ctx.ExpectedCode = http.StatusConflict
		t.Run("AutoMergePRTwice", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))

		// Read-only collaborator still cannot cancel
		readOnlyCtx.ExpectedCode = http.StatusForbidden
		t.Run("CancelAutoMergePRByReadOnlyCollaboratorForbidden", doAPICancelAutoMergePullRequest(readOnlyCtx, baseCtx.Username, baseCtx.Reponame, pr.Index))
		readOnlyCtx.ExpectedCode = 0

		// Collaborators with merge permissions can cancel a schedule made by someone else
		collaboratorCtx.ExpectedCode = http.StatusNoContent
		t.Run("CancelAutoMergePRByCollaborator", doAPICancelAutoMergePullRequest(collaboratorCtx, baseCtx.Username, baseCtx.Reponame, pr.Index))
		collaboratorCtx.ExpectedCode = 0

		// Re-add auto merge request so the repo owner can cancel it as well
		ctx.ExpectedCode = http.StatusCreated
		t.Run("AutoMergePRAfterCollaboratorCancel", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))

		// Cancel auto merge request
		ctx.ExpectedCode = http.StatusNoContent
		t.Run("CancelAutoMergePR", doAPICancelAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))

		// Collaborator can schedule but admins should still be able to cancel their schedule
		collaboratorCtx.ExpectedCode = http.StatusCreated
		t.Run("AutoMergePRByCollaborator", doAPIAutoMergePullRequest(collaboratorCtx, baseCtx.Username, baseCtx.Reponame, pr.Index))
		collaboratorCtx.ExpectedCode = 0

		ctx.ExpectedCode = http.StatusNoContent
		t.Run("CancelAutoMergePRByAdmin", doAPICancelAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))

		// Add auto merge request
		ctx.ExpectedCode = http.StatusCreated
		t.Run("AutoMergePR", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))

		// Check pr status
		ctx.ExpectedCode = 0
		pr, err = doAPIGetPullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t)
		assert.NoError(t, err)
		assert.False(t, pr.HasMerged)

		// Call API to add Failure status for commit
		t.Run("CreateStatus", doAPICreateCommitStatusTest(ctx, commitID, commitstatus.CommitStatusFailure, "testci"))

		// Check pr status
		pr, err = doAPIGetPullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t)
		assert.NoError(t, err)
		assert.False(t, pr.HasMerged)

		// Call API to add Success status for commit
		t.Run("CreateStatus", doAPICreateCommitStatusTest(ctx, commitID, commitstatus.CommitStatusSuccess, "testci"))
		// wait to let gitea merge stuff
		time.Sleep(time.Second)

		// test pr status
		pr, err = doAPIGetPullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t)
		assert.NoError(t, err)
		assert.True(t, pr.HasMerged)
	}
}

func doCreateAgitFlowPull(dstPath string, ctx *APITestContext, headBranch string) func(t *testing.T) {
	return func(t *testing.T) {
		defer tests.PrintCurrentTest(t)()

		// skip this test if git version is low
		if !git.DefaultFeatures().SupportProcReceive {
			return
		}

		gitRepo, err := git.OpenRepository(t.Context(), dstPath)
		require.NoError(t, err)

		defer gitRepo.Close()

		var (
			pr1, pr2 *issues_model.PullRequest
			commit   string
		)
		repo, err := repo_model.GetRepositoryByOwnerAndName(t.Context(), ctx.Username, ctx.Reponame)
		require.NoError(t, err)

		pullNum := unittest.GetCount(t, &issues_model.PullRequest{})

		t.Run("CreateHeadBranch", doGitCreateBranch(dstPath, headBranch))

		t.Run("AddCommit", func(t *testing.T) {
			err := os.WriteFile(path.Join(dstPath, "test_file"), []byte("## test content"), 0o666)
			require.NoError(t, err)

			err = gitAddChangesDeprecated(t.Context(), dstPath, true)
			assert.NoError(t, err)

			err = gitCommitChangesDeprecated(t.Context(), dstPath, gitCommitChangesOptions{
				Committer: &git.Signature{
					Email: "user2@example.com",
					Name:  "user2",
					When:  time.Now(),
				},
				Author: &git.Signature{
					Email: "user2@example.com",
					Name:  "user2",
					When:  time.Now(),
				},
				Message: "Testing commit 1",
			})
			assert.NoError(t, err)
			commit, err = gitRepo.GetRefCommitID("HEAD")
			assert.NoError(t, err)
		})

		t.Run("Push", func(t *testing.T) {
			err := gitcmd.NewCommand("push", "origin", "HEAD:refs/for/master", "-o").
				AddDynamicArguments("topic=" + headBranch).
				WithDir(dstPath).
				Run(t.Context())
			require.NoError(t, err)

			unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+1)
			pr1 = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
				HeadRepoID: repo.ID,
				Flow:       issues_model.PullRequestFlowAGit,
			})
			require.NotEmpty(t, pr1)

			prMsg, err := doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr1.Index)(t)
			require.NoError(t, err)

			assert.Equal(t, "user2/"+headBranch, pr1.HeadBranch)
			assert.False(t, prMsg.HasMerged)
			assert.Contains(t, "Testing commit 1", prMsg.Body)
			assert.Equal(t, commit, prMsg.Head.Sha)

			_, _, err = gitcmd.NewCommand("push", "origin").
				AddDynamicArguments("HEAD:refs/for/master/test/" + headBranch).
				WithDir(dstPath).
				RunStdString(t.Context())
			require.NoError(t, err)

			unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+2)
			pr2 = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
				HeadRepoID: repo.ID,
				Index:      pr1.Index + 1,
				Flow:       issues_model.PullRequestFlowAGit,
			})
			require.NotEmpty(t, pr2)

			prMsg, err = doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr2.Index)(t)
			require.NoError(t, err)

			assert.Equal(t, "user2/test/"+headBranch, pr2.HeadBranch)
			assert.False(t, prMsg.HasMerged)
		})

		if pr1 == nil || pr2 == nil {
			return
		}

		t.Run("AddCommit2", func(t *testing.T) {
			err := os.WriteFile(path.Join(dstPath, "test_file"), []byte("## test content \n ## test content 2"), 0o666)
			require.NoError(t, err)

			err = gitAddChangesDeprecated(t.Context(), dstPath, true)
			assert.NoError(t, err)

			err = gitCommitChangesDeprecated(t.Context(), dstPath, gitCommitChangesOptions{
				Committer: &git.Signature{
					Email: "user2@example.com",
					Name:  "user2",
					When:  time.Now(),
				},
				Author: &git.Signature{
					Email: "user2@example.com",
					Name:  "user2",
					When:  time.Now(),
				},
				Message: "Testing commit 2",
			})
			assert.NoError(t, err)
			commit, err = gitRepo.GetRefCommitID("HEAD")
			assert.NoError(t, err)
		})

		t.Run("Push2", func(t *testing.T) {
			err := gitcmd.NewCommand("push", "origin", "HEAD:refs/for/master", "-o").
				AddDynamicArguments("topic=" + headBranch).
				WithDir(dstPath).
				Run(t.Context())
			require.NoError(t, err)

			unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+2)
			prMsg, err := doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr1.Index)(t)
			require.NoError(t, err)

			assert.False(t, prMsg.HasMerged)
			assert.Equal(t, commit, prMsg.Head.Sha)

			_, _, err = gitcmd.NewCommand("push", "origin").
				AddDynamicArguments("HEAD:refs/for/master/test/" + headBranch).
				WithDir(dstPath).
				RunStdString(t.Context())
			require.NoError(t, err)

			unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+2)
			prMsg, err = doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr2.Index)(t)
			require.NoError(t, err)

			assert.False(t, prMsg.HasMerged)
			assert.Equal(t, commit, prMsg.Head.Sha)
		})
		t.Run("Merge", doAPIMergePullRequest(*ctx, ctx.Username, ctx.Reponame, pr1.Index))
		t.Run("CheckoutMasterAgain", doGitCheckoutBranch(dstPath, "master"))
	}
}
