package operations

import (
	"context"
	"strings"
	"testing"

	"github.com/stretchr/testify/require"
	"gitlab.com/gitlab-org/gitaly/v16/internal/featureflag"
	"gitlab.com/gitlab-org/gitaly/v16/internal/git"
	"gitlab.com/gitlab-org/gitaly/v16/internal/git/gittest"
	"gitlab.com/gitlab-org/gitaly/v16/internal/git/localrepo"
	"gitlab.com/gitlab-org/gitaly/v16/internal/gitaly/config"
	"gitlab.com/gitlab-org/gitaly/v16/internal/structerr"
	"gitlab.com/gitlab-org/gitaly/v16/internal/testhelper"
	"gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb"
	"google.golang.org/grpc/codes"
	"google.golang.org/protobuf/types/known/timestamppb"
)

func TestUserMergeToRef_successful(t *testing.T) {
	t.Parallel()

	testhelper.NewFeatureSets(
		featureflag.GPGSigning,
	).Run(
		t,
		testUserMergeToRefSuccessful,
	)
}

func testUserMergeToRefSuccessful(t *testing.T, ctx context.Context) {
	t.Parallel()

	ctx, cfg, client := setupOperationsService(t, ctx)

	repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg)
	repo := localrepo.NewTestRepo(t, cfg, repoProto)

	branch := "main"
	firstParentRef := "refs/heads/" + branch
	branchSha := gittest.WriteCommit(t, cfg, repoPath,
		gittest.WithReference(firstParentRef),
		gittest.WithMessage("branch commit"),
	).String()
	sourceSha := gittest.WriteCommit(t, cfg, repoPath,
		gittest.WithBranch("source-branch"),
		gittest.WithMessage("source branch commit"),
	).String()
	existingTargetRef := []byte("refs/merge-requests/x/written")
	existingTargetRefOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithReference(string(existingTargetRef)))
	emptyTargetRef := []byte("refs/merge-requests/x/merge")
	mergeCommitMessage := "Merged by Gitaly"

	testCases := []struct {
		desc           string
		user           *gitalypb.User
		branch         []byte
		targetRef      []byte
		emptyRef       bool
		sourceSha      string
		message        string
		firstParentRef []byte
		expectedOldOid string
	}{
		{
			desc:           "empty target ref merge",
			user:           gittest.TestUser,
			targetRef:      emptyTargetRef,
			emptyRef:       true,
			sourceSha:      sourceSha,
			message:        mergeCommitMessage,
			firstParentRef: []byte(firstParentRef),
		},
		{
			desc:           "existing target ref",
			user:           gittest.TestUser,
			targetRef:      existingTargetRef,
			emptyRef:       false,
			sourceSha:      sourceSha,
			message:        mergeCommitMessage,
			firstParentRef: []byte(firstParentRef),
		},
		{
			desc:           "existing target ref with optimistic lock",
			user:           gittest.TestUser,
			targetRef:      existingTargetRef,
			emptyRef:       false,
			sourceSha:      sourceSha,
			message:        mergeCommitMessage,
			firstParentRef: []byte(firstParentRef),
			expectedOldOid: existingTargetRefOid.String(),
		},
		{
			desc:      "branch is specified and firstParentRef is empty",
			user:      gittest.TestUser,
			branch:    []byte(branch),
			targetRef: existingTargetRef,
			emptyRef:  false,
			sourceSha: sourceSha,
			message:   mergeCommitMessage,
		},
	}

	for _, testCase := range testCases {
		t.Run(testCase.desc, func(t *testing.T) {
			// reset target ref
			gittest.WriteRef(t, cfg, repoPath, git.ReferenceName(existingTargetRef), existingTargetRefOid)

			request := &gitalypb.UserMergeToRefRequest{
				Repository:     repoProto,
				User:           testCase.user,
				Branch:         testCase.branch,
				TargetRef:      testCase.targetRef,
				SourceSha:      testCase.sourceSha,
				Message:        []byte(testCase.message),
				FirstParentRef: testCase.firstParentRef,
				ExpectedOldOid: testCase.expectedOldOid,
			}

			commitBeforeRefMerge, fetchRefBeforeMergeErr := repo.ReadCommit(ctx, git.Revision(testCase.targetRef))
			if testCase.emptyRef {
				require.Error(t, fetchRefBeforeMergeErr, "error when fetching empty ref commit")
			} else {
				require.NoError(t, fetchRefBeforeMergeErr, "no error when fetching existing ref")
			}

			resp, err := client.UserMergeToRef(ctx, request)
			require.NoError(t, err)

			commit, err := repo.ReadCommit(ctx, git.Revision(testCase.targetRef))
			require.NoError(t, err, "look up git commit after call has finished")

			// Asserts commit parent SHAs
			require.Equal(t, []string{branchSha, testCase.sourceSha}, commit.ParentIds, "merge commit parents must be the sha before HEAD and source sha")

			require.True(t, strings.HasPrefix(string(commit.Body), testCase.message), "expected %q to start with %q", commit.Body, testCase.message)

			// Asserts author
			author := commit.Author
			require.Equal(t, gittest.TestUser.Name, author.Name)
			require.Equal(t, gittest.TestUser.Email, author.Email)
			require.Equal(t, gittest.TimezoneOffset, string(author.Timezone))

			require.Equal(t, resp.CommitId, commit.Id)

			// Calling commitBeforeRefMerge.Id in a non-existent
			// commit will raise a null-pointer error.
			if !testCase.emptyRef {
				require.NotEqual(t, commit.Id, commitBeforeRefMerge.Id)
			}
		})
	}
}

func TestUserMergeToRef_conflicts(t *testing.T) {
	t.Parallel()

	testhelper.NewFeatureSets(
		featureflag.GPGSigning,
	).Run(
		t,
		testUserMergeToRefConflicts,
	)
}

func testUserMergeToRefConflicts(t *testing.T, ctx context.Context) {
	t.Parallel()

	ctx, cfg, client := setupOperationsService(t, ctx)

	repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg)
	common := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries(
		gittest.TreeEntry{Path: "a", Mode: "100644", Content: "base"},
	))
	left := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(common), gittest.WithTreeEntries(
		gittest.TreeEntry{Path: "a", Mode: "100644", Content: "conflicting"},
	), gittest.WithBranch("branch"))
	right := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(common), gittest.WithTreeEntries(
		gittest.TreeEntry{Path: "a", Mode: "100644", Content: "change"},
	))
	repo := localrepo.NewTestRepo(t, cfg, repoProto)

	t.Run("disallow conflicts to be merged", func(t *testing.T) {
		request := buildUserMergeToRefRequest(t, cfg, repoProto, repoPath, left, right, "disallowed-conflicts")

		_, err := client.UserMergeToRef(ctx, request)
		testhelper.RequireGrpcError(t, structerr.NewFailedPrecondition("Failed to create merge commit for source_sha %s and target_sha %s at refs/merge-requests/x/written", left, right), err)
	})

	targetRef := git.Revision("refs/merge-requests/foo")

	t.Run("failing merge does not update target reference if skipping precursor update-ref", func(t *testing.T) {
		request := buildUserMergeToRefRequest(t, cfg, repoProto, repoPath, left, right, t.Name())
		request.TargetRef = []byte(targetRef)

		_, err := client.UserMergeToRef(ctx, request)
		testhelper.RequireGrpcCode(t, err, codes.FailedPrecondition)

		hasRevision, err := repo.HasRevision(ctx, targetRef)
		require.NoError(t, err)
		require.False(t, hasRevision, "branch should not have been created")
	})
}

func buildUserMergeToRefRequest(tb testing.TB, cfg config.Cfg, repo *gitalypb.Repository, repoPath string, sourceSha, targetSha git.ObjectID, mergeBranchName string) *gitalypb.UserMergeToRefRequest {
	gittest.Exec(tb, cfg, "-C", repoPath, "branch", mergeBranchName, targetSha.String())

	return &gitalypb.UserMergeToRefRequest{
		Repository:     repo,
		User:           gittest.TestUser,
		TargetRef:      []byte("refs/merge-requests/x/written"),
		SourceSha:      sourceSha.String(),
		Message:        []byte("message1"),
		FirstParentRef: []byte("refs/heads/" + mergeBranchName),
	}
}

func TestUserMergeToRef_stableMergeID(t *testing.T) {
	t.Parallel()

	testhelper.NewFeatureSets(
		featureflag.GPGSigning,
	).Run(
		t,
		testUserMergeToRefStableMergeID,
	)
}

func testUserMergeToRefStableMergeID(t *testing.T, ctx context.Context) {
	t.Parallel()

	ctx, cfg, client := setupOperationsService(t, ctx)

	repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg)
	repo := localrepo.NewTestRepo(t, cfg, repoProto)

	common := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries(
		gittest.TreeEntry{Path: "a", Mode: "100644", Content: "1\n2\n3\n4\n5\n6\n7\n8\n"},
	))
	left := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(common), gittest.WithTreeEntries(
		gittest.TreeEntry{Path: "a", Mode: "100644", Content: "1\n2\n3\n4\n5\n6\n7\nh\n"},
	), gittest.WithBranch("branch"))
	right := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(common), gittest.WithTreeEntries(
		gittest.TreeEntry{Path: "a", Mode: "100644", Content: "a\n2\n3\n4\n5\n6\n7\n8\n"},
	))

	response, err := client.UserMergeToRef(ctx, &gitalypb.UserMergeToRefRequest{
		Repository:     repoProto,
		User:           gittest.TestUser,
		FirstParentRef: []byte("refs/heads/branch"),
		TargetRef:      []byte("refs/merge-requests/x/written"),
		SourceSha:      right.String(),
		Message:        []byte("Merge message"),
		Timestamp:      &timestamppb.Timestamp{Seconds: 12, Nanos: 34},
	})
	require.NoError(t, err)
	require.Equal(t, gittest.ObjectHashDependent(t, map[string]string{
		"sha1":   "4f295f8bb631748c7c2d0eb628d019c7802421e3",
		"sha256": "0af69a0b9550e3943892537d429a385cdc3d3ab309833744c7478a60055882e3",
	}), response.CommitId)

	commit, err := repo.ReadCommit(ctx, git.Revision("refs/merge-requests/x/written"))
	require.NoError(t, err, "look up git commit after call has finished")
	testhelper.ProtoEqual(t, &gitalypb.GitCommit{
		Subject:  []byte("Merge message"),
		Body:     []byte("Merge message"),
		BodySize: 13,
		Id: gittest.ObjectHashDependent(t, map[string]string{
			"sha1":   "4f295f8bb631748c7c2d0eb628d019c7802421e3",
			"sha256": "0af69a0b9550e3943892537d429a385cdc3d3ab309833744c7478a60055882e3",
		}),
		ParentIds: []string{
			left.String(),
			right.String(),
		},
		TreeId: gittest.ObjectHashDependent(t, map[string]string{
			"sha1":   "7ed20b777cfc00066401a4d4aa1bab50f487f346",
			"sha256": "9ff5a6fc7476b3297e176d8c7dec1c36a7a58dd68e387570229f94cce65d299c",
		}),
		Author: &gitalypb.CommitAuthor{
			Name:  gittest.TestUser.Name,
			Email: gittest.TestUser.Email,
			// Nanoseconds get ignored because commit timestamps aren't that granular.
			Date:     &timestamppb.Timestamp{Seconds: 12},
			Timezone: []byte(gittest.TimezoneOffset),
		},
		Committer: &gitalypb.CommitAuthor{
			Name:  gittest.TestUser.Name,
			Email: gittest.TestUser.Email,
			// Nanoseconds get ignored because commit timestamps aren't that granular.
			Date:     &timestamppb.Timestamp{Seconds: 12},
			Timezone: []byte(gittest.TimezoneOffset),
		},
	}, commit)
}

func TestUserMergeToRef_failure(t *testing.T) {
	testhelper.NewFeatureSets(
		featureflag.GPGSigning,
	).Run(t, testUserMergeToRefFailure)
}

func testUserMergeToRefFailure(t *testing.T, ctx context.Context) {
	t.Parallel()

	ctx, cfg, client := setupOperationsService(t, ctx)

	repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg)

	validBranchName := "main"
	gittest.WriteCommit(t, cfg, repoPath,
		gittest.WithBranch(validBranchName),
		gittest.WithMessage("branch commit"),
	)
	validSourceSha := gittest.WriteCommit(t, cfg, repoPath,
		gittest.WithBranch("source-branch"),
		gittest.WithMessage("source branch commit"),
	).String()
	validTargetRef := []byte("refs/merge-requests/x/merge")

	testCases := []struct {
		desc           string
		user           *gitalypb.User
		branch         []byte
		targetRef      []byte
		sourceSha      string
		repo           *gitalypb.Repository
		code           codes.Code
		expectedOldOid string
		message        []byte
	}{
		{
			desc:      "empty repository",
			user:      gittest.TestUser,
			branch:    []byte(validBranchName),
			sourceSha: validSourceSha,
			targetRef: validTargetRef,
			code:      codes.InvalidArgument,
		},
		{
			desc:      "empty user",
			repo:      repoProto,
			branch:    []byte(validBranchName),
			sourceSha: validSourceSha,
			targetRef: validTargetRef,
			code:      codes.InvalidArgument,
		},
		{
			desc:      "empty source SHA",
			repo:      repoProto,
			user:      gittest.TestUser,
			branch:    []byte(validBranchName),
			targetRef: validTargetRef,
			code:      codes.InvalidArgument,
		},
		{
			desc:      "non-existing commit",
			repo:      repoProto,
			user:      gittest.TestUser,
			branch:    []byte(validBranchName),
			sourceSha: "f001",
			targetRef: validTargetRef,
			code:      codes.InvalidArgument,
		},
		{
			desc:      "empty branch and first parent ref",
			repo:      repoProto,
			user:      gittest.TestUser,
			sourceSha: validSourceSha,
			targetRef: validTargetRef,
			code:      codes.InvalidArgument,
		},
		{
			desc:      "invalid target ref",
			repo:      repoProto,
			user:      gittest.TestUser,
			branch:    []byte(validBranchName),
			sourceSha: validSourceSha,
			targetRef: []byte("refs/heads/branch"),
			code:      codes.InvalidArgument,
		},
		{
			desc:      "non-existing branch",
			repo:      repoProto,
			user:      gittest.TestUser,
			branch:    []byte("this-isnt-real"),
			sourceSha: validSourceSha,
			targetRef: validTargetRef,
			code:      codes.InvalidArgument,
		},
		{
			desc:           "non-matching expected_object_id",
			repo:           repoProto,
			user:           gittest.TestUser,
			branch:         []byte(validBranchName),
			sourceSha:      validSourceSha,
			targetRef:      validTargetRef,
			code:           codes.FailedPrecondition,
			message:        []byte("some merge commit message"),
			expectedOldOid: validSourceSha, // arbitrary value that differs from current target ref OID
		},
	}

	for _, testCase := range testCases {
		t.Run(testCase.desc, func(t *testing.T) {
			request := &gitalypb.UserMergeToRefRequest{
				Repository:     testCase.repo,
				User:           testCase.user,
				Branch:         testCase.branch,
				SourceSha:      testCase.sourceSha,
				TargetRef:      testCase.targetRef,
				Message:        testCase.message,
				ExpectedOldOid: testCase.expectedOldOid,
			}
			_, err := client.UserMergeToRef(ctx, request)
			testhelper.RequireGrpcCode(t, err, testCase.code)
		})
	}
}

func TestUserMergeToRef_ignoreHooksRequest(t *testing.T) {
	t.Parallel()

	testhelper.NewFeatureSets(
		featureflag.GPGSigning,
	).Run(
		t,
		testUserMergeToRefIgnoreHooksRequest,
	)
}

func testUserMergeToRefIgnoreHooksRequest(t *testing.T, ctx context.Context) {
	t.Parallel()

	ctx, cfg, client := setupOperationsService(t, ctx)

	repo, repoPath := gittest.CreateRepository(t, ctx, cfg)
	common := gittest.WriteCommit(t, cfg, repoPath)
	gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(common), gittest.WithTreeEntries(
		gittest.TreeEntry{Path: "a", Mode: "100644", Content: "a"},
	), gittest.WithBranch("merge-me"))
	commitToMerge := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(common), gittest.WithTreeEntries(
		gittest.TreeEntry{Path: "b", Mode: "100644", Content: "b"},
	))

	request := &gitalypb.UserMergeToRefRequest{
		Repository: repo,
		SourceSha:  commitToMerge.String(),
		Branch:     []byte("merge-me"),
		TargetRef:  []byte("refs/merge-requests/x/merge"),
		User:       gittest.TestUser,
		Message:    []byte("Merge"),
	}

	hookContent := []byte("#!/bin/sh\necho 'failure'\nexit 1")

	for _, hookName := range gitlabPreHooks {
		t.Run(hookName, func(t *testing.T) {
			gittest.WriteCustomHook(t, repoPath, hookName, hookContent)

			_, err := client.UserMergeToRef(ctx, request)
			require.NoError(t, err)
		})
	}
}
