package gitdiff

import (
	"io"
	"os"
	"reflect"
	"testing"
)

func TestParseGitFileHeader(t *testing.T) {
	tests := map[string]struct {
		Input  string
		Output *File
		Err    bool
	}{
		"fileContentChange": {
			Input: `diff --git a/dir/file.txt b/dir/file.txt
index 1c23fcc..40a1b33 100644
--- a/dir/file.txt
+++ b/dir/file.txt
@@ -2,3 +4,5 @@
`,
			Output: &File{
				OldName:      "dir/file.txt",
				NewName:      "dir/file.txt",
				OldMode:      os.FileMode(0100644),
				OldOIDPrefix: "1c23fcc",
				NewOIDPrefix: "40a1b33",
			},
		},
		"newFile": {
			Input: `diff --git a/dir/file.txt b/dir/file.txt
new file mode 100644
index 0000000..f5711e4
--- /dev/null
+++ b/dir/file.txt
`,
			Output: &File{
				NewName:      "dir/file.txt",
				NewMode:      os.FileMode(0100644),
				OldOIDPrefix: "0000000",
				NewOIDPrefix: "f5711e4",
				IsNew:        true,
			},
		},
		"newEmptyFile": {
			Input: `diff --git a/empty.txt b/empty.txt
new file mode 100644
index 0000000..e69de29
`,
			Output: &File{
				NewName:      "empty.txt",
				NewMode:      os.FileMode(0100644),
				OldOIDPrefix: "0000000",
				NewOIDPrefix: "e69de29",
				IsNew:        true,
			},
		},
		"deleteFile": {
			Input: `diff --git a/dir/file.txt b/dir/file.txt
deleted file mode 100644
index 44cc321..0000000
--- a/dir/file.txt
+++ /dev/null
`,
			Output: &File{
				OldName:      "dir/file.txt",
				OldMode:      os.FileMode(0100644),
				OldOIDPrefix: "44cc321",
				NewOIDPrefix: "0000000",
				IsDelete:     true,
			},
		},
		"changeMode": {
			Input: `diff --git a/file.sh b/file.sh
old mode 100644
new mode 100755
`,
			Output: &File{
				OldName: "file.sh",
				NewName: "file.sh",
				OldMode: os.FileMode(0100644),
				NewMode: os.FileMode(0100755),
			},
		},
		"rename": {
			Input: `diff --git a/foo.txt b/bar.txt
similarity index 100%
rename from foo.txt
rename to bar.txt
`,
			Output: &File{
				OldName:  "foo.txt",
				NewName:  "bar.txt",
				Score:    100,
				IsRename: true,
			},
		},
		"copy": {
			Input: `diff --git a/file.txt b/copy.txt
similarity index 100%
copy from file.txt
copy to copy.txt
`,
			Output: &File{
				OldName: "file.txt",
				NewName: "copy.txt",
				Score:   100,
				IsCopy:  true,
			},
		},
		"missingDefaultFilename": {
			Input: `diff --git a/foo.sh b/bar.sh
old mode 100644
new mode 100755
`,
			Err: true,
		},
		"missingNewFilename": {
			Input: `diff --git a/file.txt b/file.txt
index 1c23fcc..40a1b33 100644
--- a/file.txt
`,
			Err: true,
		},
		"missingOldFilename": {
			Input: `diff --git a/file.txt b/file.txt
index 1c23fcc..40a1b33 100644
+++ b/file.txt
`,
			Err: true,
		},
		"invalidHeaderLine": {
			Input: `diff --git a/file.txt b/file.txt
index deadbeef
--- a/file.txt
+++ b/file.txt
`,
			Err: true,
		},
		"notGitHeader": {
			Input: `--- file.txt
+++ file.txt
@@ -0,0 +1 @@
`,
			Output: nil,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			p := newTestParser(test.Input, true)

			f, err := p.ParseGitFileHeader()
			if test.Err {
				if err == nil || err == io.EOF {
					t.Fatalf("expected error parsing git file header, got %v", err)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error parsing git file header: %v", err)
			}

			if !reflect.DeepEqual(test.Output, f) {
				t.Errorf("incorrect file\nexpected: %+v\n  actual: %+v", test.Output, f)
			}
		})
	}
}

func TestParseTraditionalFileHeader(t *testing.T) {
	tests := map[string]struct {
		Input  string
		Output *File
		Err    bool
	}{
		"fileContentChange": {
			Input: `--- dir/file_old.txt	2019-03-21 23:00:00.0 -0700
+++ dir/file_new.txt	2019-03-21 23:30:00.0 -0700
@@ -0,0 +1 @@
`,
			Output: &File{
				OldName: "dir/file_new.txt",
				NewName: "dir/file_new.txt",
			},
		},
		"newFile": {
			Input: `--- /dev/null	1969-12-31 17:00:00.0 -0700
+++ dir/file.txt	2019-03-21 23:30:00.0 -0700
@@ -0,0 +1 @@
`,
			Output: &File{
				NewName: "dir/file.txt",
				IsNew:   true,
			},
		},
		"newFileTimestamp": {
			Input: `--- dir/file.txt	1969-12-31 17:00:00.0 -0700
+++ dir/file.txt	2019-03-21 23:30:00.0 -0700
@@ -0,0 +1 @@
`,
			Output: &File{
				NewName: "dir/file.txt",
				IsNew:   true,
			},
		},
		"deleteFile": {
			Input: `--- dir/file.txt	2019-03-21 23:30:00.0 -0700
+++ /dev/null	1969-12-31 17:00:00.0 -0700
@@ -0,0 +1 @@
`,
			Output: &File{
				OldName:  "dir/file.txt",
				IsDelete: true,
			},
		},
		"deleteFileTimestamp": {
			Input: `--- dir/file.txt	2019-03-21 23:30:00.0 -0700
+++ dir/file.txt	1969-12-31 17:00:00.0 -0700
@@ -0,0 +1 @@
`,
			Output: &File{
				OldName:  "dir/file.txt",
				IsDelete: true,
			},
		},
		"useShortestPrefixName": {
			Input: `--- dir/file.txt	2019-03-21 23:00:00.0 -0700
+++ dir/file.txt~	2019-03-21 23:30:00.0 -0700
@@ -0,0 +1 @@
`,
			Output: &File{
				OldName: "dir/file.txt",
				NewName: "dir/file.txt",
			},
		},
		"notTraditionalHeader": {
			Input: `diff --git a/dir/file.txt b/dir/file.txt
--- a/dir/file.txt
+++ b/dir/file.txt
`,
			Output: nil,
		},
		"noUnifiedFragment": {
			Input: `--- dir/file_old.txt	2019-03-21 23:00:00.0 -0700
+++ dir/file_new.txt	2019-03-21 23:30:00.0 -0700
context line
+added line
`,
			Output: nil,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			p := newTestParser(test.Input, true)

			f, err := p.ParseTraditionalFileHeader()
			if test.Err {
				if err == nil || err == io.EOF {
					t.Fatalf("expected error parsing traditional file header, got %v", err)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error parsing traditional file header: %v", err)
			}

			if !reflect.DeepEqual(test.Output, f) {
				t.Errorf("incorrect file\nexpected: %+v\n  actual: %+v", test.Output, f)
			}
		})
	}
}

func TestCleanName(t *testing.T) {
	tests := map[string]struct {
		Input  string
		Drop   int
		Output string
	}{
		"alreadyClean": {
			Input: "a/b/c.txt", Output: "a/b/c.txt",
		},
		"doubleSlashes": {
			Input: "a//b/c.txt", Output: "a/b/c.txt",
		},
		"tripleSlashes": {
			Input: "a///b/c.txt", Output: "a/b/c.txt",
		},
		"dropPrefix": {
			Input: "a/b/c.txt", Drop: 2, Output: "c.txt",
		},
		"removeDoublesBeforeDrop": {
			Input: "a//b/c.txt", Drop: 1, Output: "b/c.txt",
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			output := cleanName(test.Input, test.Drop)
			if output != test.Output {
				t.Fatalf("incorrect output: expected %q, actual %q", test.Output, output)
			}
		})
	}
}

func TestParseName(t *testing.T) {
	tests := map[string]struct {
		Input  string
		Term   byte
		Drop   int
		Output string
		N      int
		Err    bool
	}{
		"singleUnquoted": {
			Input: "dir/file.txt", Output: "dir/file.txt", N: 12,
		},
		"singleQuoted": {
			Input: `"dir/file.txt"`, Output: "dir/file.txt", N: 14,
		},
		"quotedWithEscape": {
			Input: `"dir/\"quotes\".txt"`, Output: `dir/"quotes".txt`, N: 20,
		},
		"quotedWithSpaces": {
			Input: `"dir/space file.txt"`, Output: "dir/space file.txt", N: 20,
		},
		"tabTerminator": {
			Input: "dir/space file.txt\tfile2.txt", Term: '\t', Output: "dir/space file.txt", N: 18,
		},
		"dropPrefix": {
			Input: "a/dir/file.txt", Drop: 1, Output: "dir/file.txt", N: 14,
		},
		"unquotedWithSpaces": {
			Input: "dir/with spaces.txt", Output: "dir/with spaces.txt", N: 19,
		},
		"unquotedWithTrailingSpaces": {
			Input: "dir/with spaces.space  ", Output: "dir/with spaces.space  ", N: 23,
		},
		"devNull": {
			Input: "/dev/null", Term: '\t', Drop: 1, Output: "/dev/null", N: 9,
		},
		"newlineSeparates": {
			Input: "dir/file.txt\n", Output: "dir/file.txt", N: 12,
		},
		"emptyString": {
			Input: "", Err: true,
		},
		"emptyQuotedString": {
			Input: `""`, Err: true,
		},
		"unterminatedQuotes": {
			Input: `"dir/file.txt`, Err: true,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			output, n, err := parseName(test.Input, test.Term, test.Drop)
			if test.Err {
				if err == nil || err == io.EOF {
					t.Fatalf("expected error parsing name, but got %v", err)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error parsing name: %v", err)
			}

			if output != test.Output {
				t.Errorf("incorrect output: expected %q, actual: %q", test.Output, output)
			}
			if n != test.N {
				t.Errorf("incorrect next position: expected %d, actual %d", test.N, n)
			}
		})
	}
}

func TestParseGitHeaderData(t *testing.T) {
	tests := map[string]struct {
		InputFile   *File
		Line        string
		DefaultName string

		OutputFile *File
		End        bool
		Err        bool
	}{
		"fragementEndsParsing": {
			Line: "@@ -12,3 +12,2 @@\n",
			End:  true,
		},
		"unknownEndsParsing": {
			Line: "GIT binary file\n",
			End:  true,
		},
		"oldFileName": {
			Line: "--- a/dir/file.txt\n",
			OutputFile: &File{
				OldName: "dir/file.txt",
			},
		},
		"oldFileNameDevNull": {
			InputFile: &File{
				IsNew: true,
			},
			Line: "--- /dev/null\n",
			OutputFile: &File{
				IsNew: true,
			},
		},
		"oldFileNameInconsistent": {
			InputFile: &File{
				OldName: "dir/foo.txt",
			},
			Line: "--- a/dir/bar.txt\n",
			Err:  true,
		},
		"oldFileNameExistingCreateMismatch": {
			InputFile: &File{
				OldName: "dir/foo.txt",
				IsNew:   true,
			},
			Line: "--- /dev/null\n",
			Err:  true,
		},
		"oldFileNameParsedCreateMismatch": {
			InputFile: &File{
				IsNew: true,
			},
			Line: "--- a/dir/file.txt\n",
			Err:  true,
		},
		"oldFileNameMissing": {
			Line: "--- \n",
			Err:  true,
		},
		"newFileName": {
			Line: "+++ b/dir/file.txt\n",
			OutputFile: &File{
				NewName: "dir/file.txt",
			},
		},
		"newFileNameDevNull": {
			InputFile: &File{
				IsDelete: true,
			},
			Line: "+++ /dev/null\n",
			OutputFile: &File{
				IsDelete: true,
			},
		},
		"newFileNameInconsistent": {
			InputFile: &File{
				NewName: "dir/foo.txt",
			},
			Line: "+++ b/dir/bar.txt\n",
			Err:  true,
		},
		"newFileNameExistingDeleteMismatch": {
			InputFile: &File{
				NewName:  "dir/foo.txt",
				IsDelete: true,
			},
			Line: "+++ /dev/null\n",
			Err:  true,
		},
		"newFileNameParsedDeleteMismatch": {
			InputFile: &File{
				IsDelete: true,
			},
			Line: "+++ b/dir/file.txt\n",
			Err:  true,
		},
		"newFileNameMissing": {
			Line: "+++ \n",
			Err:  true,
		},
		"oldMode": {
			Line: "old mode 100644\n",
			OutputFile: &File{
				OldMode: os.FileMode(0100644),
			},
		},
		"oldModeWithTrailingSpace": {
			Line: "old mode 100644\r\n",
			OutputFile: &File{
				OldMode: os.FileMode(0100644),
			},
		},
		"invalidOldMode": {
			Line: "old mode rw\n",
			Err:  true,
		},
		"newMode": {
			Line: "new mode 100755\n",
			OutputFile: &File{
				NewMode: os.FileMode(0100755),
			},
		},
		"newModeWithTrailingSpace": {
			Line: "new mode 100755\r\n",
			OutputFile: &File{
				NewMode: os.FileMode(0100755),
			},
		},
		"invalidNewMode": {
			Line: "new mode rwx\n",
			Err:  true,
		},
		"deletedFileMode": {
			Line:        "deleted file mode 100644\n",
			DefaultName: "dir/file.txt",
			OutputFile: &File{
				OldName:  "dir/file.txt",
				OldMode:  os.FileMode(0100644),
				IsDelete: true,
			},
		},
		"newFileMode": {
			Line:        "new file mode 100755\n",
			DefaultName: "dir/file.txt",
			OutputFile: &File{
				NewName: "dir/file.txt",
				NewMode: os.FileMode(0100755),
				IsNew:   true,
			},
		},
		"newFileModeWithTrailingSpace": {
			Line:        "new file mode 100755\r\n",
			DefaultName: "dir/file.txt",
			OutputFile: &File{
				NewName: "dir/file.txt",
				NewMode: os.FileMode(0100755),
				IsNew:   true,
			},
		},
		"copyFrom": {
			Line: "copy from dir/file.txt\n",
			OutputFile: &File{
				OldName: "dir/file.txt",
				IsCopy:  true,
			},
		},
		"copyTo": {
			Line: "copy to dir/file.txt\n",
			OutputFile: &File{
				NewName: "dir/file.txt",
				IsCopy:  true,
			},
		},
		"renameFrom": {
			Line: "rename from dir/file.txt\n",
			OutputFile: &File{
				OldName:  "dir/file.txt",
				IsRename: true,
			},
		},
		"renameTo": {
			Line: "rename to dir/file.txt\n",
			OutputFile: &File{
				NewName:  "dir/file.txt",
				IsRename: true,
			},
		},
		"similarityIndex": {
			Line: "similarity index 88%\n",
			OutputFile: &File{
				Score: 88,
			},
		},
		"similarityIndexTooBig": {
			Line: "similarity index 9001%\n",
			OutputFile: &File{
				Score: 0,
			},
		},
		"similarityIndexInvalid": {
			Line: "similarity index 12ab%\n",
			Err:  true,
		},
		"indexFullSHA1AndMode": {
			Line: "index 79c6d7f7b7e76c75b3d238f12fb1323f2333ba14..04fab916d8f938173cbb8b93469855f0e838f098 100644\n",
			OutputFile: &File{
				OldOIDPrefix: "79c6d7f7b7e76c75b3d238f12fb1323f2333ba14",
				NewOIDPrefix: "04fab916d8f938173cbb8b93469855f0e838f098",
				OldMode:      os.FileMode(0100644),
			},
		},
		"indexFullSHA1NoMode": {
			Line: "index 79c6d7f7b7e76c75b3d238f12fb1323f2333ba14..04fab916d8f938173cbb8b93469855f0e838f098\n",
			OutputFile: &File{
				OldOIDPrefix: "79c6d7f7b7e76c75b3d238f12fb1323f2333ba14",
				NewOIDPrefix: "04fab916d8f938173cbb8b93469855f0e838f098",
			},
		},
		"indexAbbrevSHA1AndMode": {
			Line: "index 79c6d7..04fab9 100644\n",
			OutputFile: &File{
				OldOIDPrefix: "79c6d7",
				NewOIDPrefix: "04fab9",
				OldMode:      os.FileMode(0100644),
			},
		},
		"indexInvalid": {
			Line: "index 79c6d7f7b7e76c75b3d238f12fb1323f2333ba14\n",
			Err:  true,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			var f File
			if test.InputFile != nil {
				f = *test.InputFile
			}

			end, err := parseGitHeaderData(&f, test.Line, test.DefaultName)
			if test.Err {
				if err == nil || err == io.EOF {
					t.Fatalf("expected error parsing header data, but got %v", err)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error parsing header data: %v", err)
			}

			if test.OutputFile != nil && !reflect.DeepEqual(test.OutputFile, &f) {
				t.Errorf("incorrect output:\nexpected: %+v\nactual: %+v", test.OutputFile, &f)
			}
			if end != test.End {
				t.Errorf("incorrect end state, expected %t, actual %t", test.End, end)
			}
		})
	}
}

func TestParseGitHeaderName(t *testing.T) {
	tests := map[string]struct {
		Input  string
		Output string
		Err    bool
	}{
		"twoMatchingNames": {
			Input:  "a/dir/file.txt b/dir/file.txt",
			Output: "dir/file.txt",
		},
		"twoDifferentNames": {
			Input:  "a/dir/foo.txt b/dir/bar.txt",
			Output: "",
		},
		"matchingNamesWithSpaces": {
			Input:  "a/dir/file with spaces.txt b/dir/file with spaces.txt",
			Output: "dir/file with spaces.txt",
		},
		"matchingNamesWithTrailingSpaces": {
			Input:  "a/dir/spaces   b/dir/spaces  ",
			Output: "dir/spaces  ",
		},
		"matchingNamesQuoted": {
			Input:  `"a/dir/\"quotes\".txt" "b/dir/\"quotes\".txt"`,
			Output: `dir/"quotes".txt`,
		},
		"matchingNamesFirstQuoted": {
			Input:  `"a/dir/file.txt" b/dir/file.txt`,
			Output: "dir/file.txt",
		},
		"matchingNamesSecondQuoted": {
			Input:  `a/dir/file.txt "b/dir/file.txt"`,
			Output: "dir/file.txt",
		},
		"noSecondName": {
			Input:  "a/dir/foo.txt",
			Output: "",
		},
		"noSecondNameQuoted": {
			Input:  `"a/dir/foo.txt"`,
			Output: "",
		},
		"invalidName": {
			Input: `"a/dir/file.txt b/dir/file.txt`,
			Err:   true,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			output, err := parseGitHeaderName(test.Input)
			if test.Err {
				if err == nil {
					t.Fatalf("expected error parsing header name, but got nil")
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error parsing header name: %v", err)
			}

			if output != test.Output {
				t.Errorf("incorrect output: expected %q, actual %q", test.Output, output)
			}
		})
	}
}

func TestHasEpochTimestamp(t *testing.T) {
	tests := map[string]struct {
		Input  string
		Output bool
	}{
		"utcTimestamp": {
			Input:  "+++ file.txt\t1970-01-01 00:00:00 +0000\n",
			Output: true,
		},
		"utcZoneWithColon": {
			Input:  "+++ file.txt\t1970-01-01 00:00:00 +00:00\n",
			Output: true,
		},
		"utcZoneWithMilliseconds": {
			Input:  "+++ file.txt\t1970-01-01 00:00:00.000000 +00:00\n",
			Output: true,
		},
		"westTimestamp": {
			Input:  "+++ file.txt\t1969-12-31 16:00:00 -0800\n",
			Output: true,
		},
		"eastTimestamp": {
			Input:  "+++ file.txt\t1970-01-01 04:00:00 +0400\n",
			Output: true,
		},
		"noTab": {
			Input:  "+++ file.txt 1970-01-01 00:00:00 +0000\n",
			Output: false,
		},
		"invalidFormat": {
			Input:  "+++ file.txt\t1970-01-01T00:00:00Z\n",
			Output: false,
		},
		"notEpoch": {
			Input:  "+++ file.txt\t2019-03-21 12:34:56.789 -0700\n",
			Output: false,
		},
		"notTimestamp": {
			Input:  "+++ file.txt\trandom text\n",
			Output: false,
		},
		"notTimestampShort": {
			Input:  "+++ file.txt\t0\n",
			Output: false,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			output := hasEpochTimestamp(test.Input)
			if output != test.Output {
				t.Errorf("incorrect output: expected %t, actual %t", test.Output, output)
			}
		})
	}
}
