package localrepo

import (
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"gitlab.com/gitlab-org/gitaly/v14/internal/git"
	"gitlab.com/gitlab-org/gitaly/v14/internal/git/gittest"
	"gitlab.com/gitlab-org/gitaly/v14/internal/helper/text"
	"gitlab.com/gitlab-org/gitaly/v14/internal/testhelper"
	"gitlab.com/gitlab-org/gitaly/v14/proto/go/gitalypb"
	"google.golang.org/protobuf/types/known/timestamppb"
)

type ReaderFunc func([]byte) (int, error)

func (fn ReaderFunc) Read(b []byte) (int, error) { return fn(b) }

func TestRepo_WriteBlob(t *testing.T) {
	ctx := testhelper.Context(t)

	_, repo, repoPath := setupRepo(t, withEmptyRepo())

	for _, tc := range []struct {
		desc       string
		attributes string
		input      io.Reader
		sha        string
		error      error
		content    string
	}{
		{
			desc:  "error reading",
			input: ReaderFunc(func([]byte) (int, error) { return 0, assert.AnError }),
			error: assert.AnError,
		},
		{
			desc:    "successful empty blob",
			input:   strings.NewReader(""),
			content: "",
		},
		{
			desc:    "successful blob",
			input:   strings.NewReader("some content"),
			content: "some content",
		},
		{
			desc:    "LF line endings left unmodified",
			input:   strings.NewReader("\n"),
			content: "\n",
		},
		{
			desc:    "CRLF converted to LF due to global git config",
			input:   strings.NewReader("\r\n"),
			content: "\n",
		},
		{
			desc:       "line endings preserved in binary files",
			input:      strings.NewReader("\r\n"),
			attributes: "file-path binary",
			content:    "\r\n",
		},
	} {
		t.Run(tc.desc, func(t *testing.T) {
			attributesPath := filepath.Join(repoPath, "info", "attributes")
			require.NoError(t, os.MkdirAll(filepath.Dir(attributesPath), 0o755))
			require.NoError(t, os.WriteFile(attributesPath, []byte(tc.attributes), os.ModePerm))

			sha, err := repo.WriteBlob(ctx, "file-path", tc.input)
			require.Equal(t, tc.error, err)
			if tc.error != nil {
				return
			}

			content, err := repo.ReadObject(ctx, sha)
			require.NoError(t, err)
			assert.Equal(t, tc.content, string(content))
		})
	}
}

func TestFormatTag(t *testing.T) {
	for _, tc := range []struct {
		desc       string
		objectID   git.ObjectID
		objectType string
		tagName    []byte
		tagBody    []byte
		author     *gitalypb.User
		authorDate time.Time
		err        error
	}{
		// Just trivial tests here, most of this is tested in
		// internal/gitaly/service/operations/tags_test.go
		{
			desc:       "basic signature",
			objectID:   git.ZeroOID,
			objectType: "commit",
			tagName:    []byte("my-tag"),
			author: &gitalypb.User{
				Name:  []byte("root"),
				Email: []byte("root@localhost"),
			},
			tagBody: []byte(""),
		},
		{
			desc:       "basic signature",
			objectID:   git.ZeroOID,
			objectType: "commit",
			tagName:    []byte("my-tag\ninjection"),
			tagBody:    []byte(""),
			author: &gitalypb.User{
				Name:  []byte("root"),
				Email: []byte("root@localhost"),
			},
			err: FormatTagError{expectedLines: 4, actualLines: 5},
		},
		{
			desc:       "signature with fixed time",
			objectID:   git.ZeroOID,
			objectType: "commit",
			tagName:    []byte("my-tag"),
			tagBody:    []byte(""),
			author: &gitalypb.User{
				Name:  []byte("root"),
				Email: []byte("root@localhost"),
			},
			authorDate: time.Unix(12345, 0),
		},
	} {
		t.Run(tc.desc, func(t *testing.T) {
			signature, err := FormatTag(tc.objectID, tc.objectType, tc.tagName, tc.tagBody, tc.author, tc.authorDate)
			if err != nil {
				require.Equal(t, tc.err, err)
				require.Equal(t, "", signature)
			} else {
				require.NoError(t, err)
				require.Contains(t, signature, "object ")
				require.Contains(t, signature, "tag ")
				require.Contains(t, signature, "tagger ")
			}
		})
	}
}

func TestRepo_WriteTag(t *testing.T) {
	ctx := testhelper.Context(t)

	cfg, repo, repoPath := setupRepo(t)

	for _, tc := range []struct {
		desc        string
		objectID    git.ObjectID
		objectType  string
		tagName     []byte
		tagBody     []byte
		author      *gitalypb.User
		authorDate  time.Time
		expectedTag string
	}{
		// Just trivial tests here, most of this is tested in
		// internal/gitaly/service/operations/tags_test.go
		{
			desc:       "basic signature",
			objectID:   "c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd",
			objectType: "commit",
			tagName:    []byte("my-tag"),
			tagBody:    []byte(""),
			author: &gitalypb.User{
				Name:  []byte("root"),
				Email: []byte("root@localhost"),
			},
		},
		{
			desc:       "signature with time",
			objectID:   "c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd",
			objectType: "commit",
			tagName:    []byte("tag-with-timestamp"),
			tagBody:    []byte(""),
			author: &gitalypb.User{
				Name:  []byte("root"),
				Email: []byte("root@localhost"),
			},
			authorDate: time.Unix(12345, 0).UTC(),
			expectedTag: `object c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd
type commit
tag tag-with-timestamp
tagger root <root@localhost> 12345 +0000
`,
		},
		{
			desc:       "signature with time and timezone",
			objectID:   "c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd",
			objectType: "commit",
			tagName:    []byte("tag-with-timezone"),
			tagBody:    []byte(""),
			author: &gitalypb.User{
				Name:  []byte("root"),
				Email: []byte("root@localhost"),
			},
			authorDate: time.Unix(12345, 0).In(time.FixedZone("myzone", -60*60)),
			expectedTag: `object c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd
type commit
tag tag-with-timezone
tagger root <root@localhost> 12345 -0100
`,
		},
	} {
		t.Run(tc.desc, func(t *testing.T) {
			tagObjID, err := repo.WriteTag(ctx, tc.objectID, tc.objectType, tc.tagName, tc.tagBody, tc.author, tc.authorDate)
			require.NoError(t, err)

			repoTagObjID := gittest.Exec(t, cfg, "-C", repoPath, "rev-parse", tagObjID.String())
			require.Equal(t, text.ChompBytes(repoTagObjID), tagObjID.String())

			if tc.expectedTag != "" {
				tag := gittest.Exec(t, cfg, "-C", repoPath, "cat-file", "-p", tagObjID.String())
				require.Equal(t, tc.expectedTag, text.ChompBytes(tag))
			}
		})
	}
}

func TestRepo_ReadObject(t *testing.T) {
	ctx := testhelper.Context(t)

	_, repo, _ := setupRepo(t)

	for _, tc := range []struct {
		desc    string
		oid     git.ObjectID
		content string
		error   error
	}{
		{
			desc:  "invalid object",
			oid:   git.ZeroOID,
			error: InvalidObjectError(git.ZeroOID.String()),
		},
		{
			desc: "valid object",
			// README in gitlab-test
			oid:     "3742e48c1108ced3bf45ac633b34b65ac3f2af04",
			content: "Sample repo for testing gitlab features\n",
		},
	} {
		t.Run(tc.desc, func(t *testing.T) {
			content, err := repo.ReadObject(ctx, tc.oid)
			require.Equal(t, tc.error, err)
			require.Equal(t, tc.content, string(content))
		})
	}
}

func TestRepo_ReadCommit(t *testing.T) {
	ctx := testhelper.Context(t)

	_, repo, _ := setupRepo(t)

	for _, tc := range []struct {
		desc           string
		revision       git.Revision
		opts           []ReadCommitOpt
		expectedCommit *gitalypb.GitCommit
		expectedErr    error
	}{
		{
			desc:        "invalid commit",
			revision:    git.ZeroOID.Revision(),
			expectedErr: ErrObjectNotFound,
		},
		{
			desc:        "invalid commit with trailers",
			revision:    git.ZeroOID.Revision(),
			expectedErr: ErrObjectNotFound,
			opts:        []ReadCommitOpt{WithTrailers()},
		},
		{
			desc:     "valid commit",
			revision: "refs/heads/master",
			expectedCommit: &gitalypb.GitCommit{
				Id:     "1e292f8fedd741b75372e19097c76d327140c312",
				TreeId: "07f8147e8e73aab6c935c296e8cdc5194dee729b",
				ParentIds: []string{
					"7975be0116940bf2ad4321f79d02a55c5f7779aa",
					"c1c67abbaf91f624347bb3ae96eabe3a1b742478",
				},
				Subject:  []byte("Merge branch 'cherry-pick-ce369011' into 'master'"),
				Body:     []byte("Merge branch 'cherry-pick-ce369011' into 'master'\n\nAdd file with a _flattable_ path\n\nSee merge request gitlab-org/gitlab-test!35"),
				BodySize: 128,
				Author: &gitalypb.CommitAuthor{
					Name:  []byte("Drew Blessing"),
					Email: []byte("drew@blessing.io"),
					Date: &timestamppb.Timestamp{
						Seconds: 1540830087,
					},
					Timezone: []byte("+0000"),
				},
				Committer: &gitalypb.CommitAuthor{
					Name:  []byte("Drew Blessing"),
					Email: []byte("drew@blessing.io"),
					Date: &timestamppb.Timestamp{
						Seconds: 1540830087,
					},
					Timezone: []byte("+0000"),
				},
			},
		},
		{
			desc:     "trailers do not get parsed without WithTrailers()",
			revision: "5937ac0a7beb003549fc5fd26fc247adbce4a52e",
			expectedCommit: &gitalypb.GitCommit{
				Id:     "5937ac0a7beb003549fc5fd26fc247adbce4a52e",
				TreeId: "a6973545d42361b28bfba5ced3b75dba5848b955",
				ParentIds: []string{
					"570e7b2abdd848b95f2f578043fc23bd6f6fd24d",
				},
				Subject:  []byte("Add submodule from gitlab.com"),
				Body:     []byte("Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n"),
				BodySize: 98,
				Author: &gitalypb.CommitAuthor{
					Name:  []byte("Dmitriy Zaporozhets"),
					Email: []byte("dmitriy.zaporozhets@gmail.com"),
					Date: &timestamppb.Timestamp{
						Seconds: 1393491698,
					},
					Timezone: []byte("+0200"),
				},
				Committer: &gitalypb.CommitAuthor{
					Name:  []byte("Dmitriy Zaporozhets"),
					Email: []byte("dmitriy.zaporozhets@gmail.com"),
					Date: &timestamppb.Timestamp{
						Seconds: 1393491698,
					},
					Timezone: []byte("+0200"),
				},
				SignatureType: gitalypb.SignatureType_PGP,
			},
		},
		{
			desc:     "trailers get parsed with WithTrailers()",
			revision: "5937ac0a7beb003549fc5fd26fc247adbce4a52e",
			opts:     []ReadCommitOpt{WithTrailers()},
			expectedCommit: &gitalypb.GitCommit{
				Id:     "5937ac0a7beb003549fc5fd26fc247adbce4a52e",
				TreeId: "a6973545d42361b28bfba5ced3b75dba5848b955",
				ParentIds: []string{
					"570e7b2abdd848b95f2f578043fc23bd6f6fd24d",
				},
				Subject:  []byte("Add submodule from gitlab.com"),
				Body:     []byte("Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n"),
				BodySize: 98,
				Author: &gitalypb.CommitAuthor{
					Name:  []byte("Dmitriy Zaporozhets"),
					Email: []byte("dmitriy.zaporozhets@gmail.com"),
					Date: &timestamppb.Timestamp{
						Seconds: 1393491698,
					},
					Timezone: []byte("+0200"),
				},
				Committer: &gitalypb.CommitAuthor{
					Name:  []byte("Dmitriy Zaporozhets"),
					Email: []byte("dmitriy.zaporozhets@gmail.com"),
					Date: &timestamppb.Timestamp{
						Seconds: 1393491698,
					},
					Timezone: []byte("+0200"),
				},
				SignatureType: gitalypb.SignatureType_PGP,
				Trailers: []*gitalypb.CommitTrailer{
					{
						Key:   []byte("Signed-off-by"),
						Value: []byte("Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>"),
					},
				},
			},
		},
		{
			desc:        "not a commit",
			revision:    "refs/heads/master^{tree}",
			expectedErr: ErrObjectNotFound,
		},
	} {
		t.Run(tc.desc, func(t *testing.T) {
			commit, err := repo.ReadCommit(ctx, tc.revision, tc.opts...)
			require.Equal(t, tc.expectedErr, err)
			require.Equal(t, tc.expectedCommit, commit)
		})
	}
}

func TestRepo_IsAncestor(t *testing.T) {
	ctx := testhelper.Context(t)

	_, repo, _ := setupRepo(t)

	for _, tc := range []struct {
		desc         string
		parent       git.Revision
		child        git.Revision
		isAncestor   bool
		errorMatcher func(testing.TB, error)
	}{
		{
			desc:       "parent is ancestor",
			parent:     "HEAD~1",
			child:      "HEAD",
			isAncestor: true,
		},
		{
			desc:       "parent is not ancestor",
			parent:     "HEAD",
			child:      "HEAD~1",
			isAncestor: false,
		},
		{
			desc:   "parent is not valid commit",
			parent: git.ZeroOID.Revision(),
			child:  "HEAD",
			errorMatcher: func(t testing.TB, err error) {
				require.Equal(t, InvalidCommitError(git.ZeroOID), err)
			},
		},
		{
			desc:   "child is not valid commit",
			parent: "HEAD",
			child:  git.ZeroOID.Revision(),
			errorMatcher: func(t testing.TB, err error) {
				require.Equal(t, InvalidCommitError(git.ZeroOID), err)
			},
		},
		{
			desc:   "child points to a tree",
			parent: "HEAD",
			child:  "HEAD^{tree}",
			errorMatcher: func(t testing.TB, actualErr error) {
				treeOID, err := repo.ResolveRevision(ctx, "HEAD^{tree}")
				require.NoError(t, err)
				require.EqualError(t, actualErr, fmt.Sprintf(
					`determine ancestry: exit status 128, stderr: "error: object %s is a tree, not a commit\nfatal: Not a valid commit name HEAD^{tree}\n"`,
					treeOID,
				))
			},
		},
	} {
		t.Run(tc.desc, func(t *testing.T) {
			isAncestor, err := repo.IsAncestor(ctx, tc.parent, tc.child)
			if tc.errorMatcher != nil {
				tc.errorMatcher(t, err)
				return
			}

			require.NoError(t, err)
			require.Equal(t, tc.isAncestor, isAncestor)
		})
	}
}
