package object

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"strings"
	"time"

	fixtures "github.com/go-git/go-git-fixtures/v4"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/cache"

	"github.com/go-git/go-git/v5/storage/filesystem"
	. "gopkg.in/check.v1"
)

type SuiteCommit struct {
	BaseObjectsSuite
	Commit *Commit
}

var _ = Suite(&SuiteCommit{})

func (s *SuiteCommit) SetUpSuite(c *C) {
	s.BaseObjectsSuite.SetUpSuite(c)

	hash := plumbing.NewHash("1669dce138d9b841a518c64b10914d88f5e488ea")

	s.Commit = s.commit(c, hash)
}

func (s *SuiteCommit) TestDecodeNonCommit(c *C) {
	hash := plumbing.NewHash("9a48f23120e880dfbe41f7c9b7b708e9ee62a492")
	blob, err := s.Storer.EncodedObject(plumbing.AnyObject, hash)
	c.Assert(err, IsNil)

	commit := &Commit{}
	err = commit.Decode(blob)
	c.Assert(err, Equals, ErrUnsupportedObject)
}

func (s *SuiteCommit) TestDecodeClearsExistingState(c *C) {
	const raw = "tree eba74343e2f15d62adedfd8c883ee0262b5c8021\n\nfresh message\n"

	staleSrc := &plumbing.MemoryObject{}
	commit := &Commit{
		Hash:         plumbing.NewHash("1111111111111111111111111111111111111111"),
		Author:       Signature{Name: "Stale Author", Email: "author@example.local", When: time.Unix(1, 0).UTC()},
		Committer:    Signature{Name: "Stale Committer", Email: "committer@example.local", When: time.Unix(2, 0).UTC()},
		MergeTag:     "stale merge tag",
		PGPSignature: "stale signature",
		Message:      "stale message",
		TreeHash:     plumbing.NewHash("2222222222222222222222222222222222222222"),
		ParentHashes: []plumbing.Hash{
			plumbing.NewHash("3333333333333333333333333333333333333333"),
		},
		Encoding: MessageEncoding("latin-1"),
		ExtraHeaders: []ExtraHeader{
			{Key: "x-stale", Value: "stale"},
		},
		s:   s.Storer,
		src: staleSrc,
	}

	obj := &plumbing.MemoryObject{}
	obj.SetType(plumbing.CommitObject)
	_, err := obj.Write([]byte(raw))
	c.Assert(err, IsNil)

	c.Assert(commit.Decode(obj), IsNil)
	c.Assert(commit.Hash, Equals, obj.Hash())
	c.Assert(commit.Author, DeepEquals, Signature{})
	c.Assert(commit.Committer, DeepEquals, Signature{})
	c.Assert(commit.MergeTag, Equals, "")
	c.Assert(commit.PGPSignature, Equals, "")
	c.Assert(commit.Message, Equals, "fresh message\n")
	c.Assert(commit.TreeHash.String(), Equals, "eba74343e2f15d62adedfd8c883ee0262b5c8021")
	c.Assert(commit.ParentHashes, IsNil)
	c.Assert(commit.Encoding, Equals, defaultUtf8CommitMessageEncoding)
	c.Assert(commit.ExtraHeaders, IsNil)
	c.Assert(commit.s, Equals, s.Storer)
	c.Assert(commit.src, Equals, obj)
}

func (s *SuiteCommit) TestType(c *C) {
	c.Assert(s.Commit.Type(), Equals, plumbing.CommitObject)
}

func (s *SuiteCommit) TestTree(c *C) {
	tree, err := s.Commit.Tree()
	c.Assert(err, IsNil)
	c.Assert(tree.ID().String(), Equals, "eba74343e2f15d62adedfd8c883ee0262b5c8021")
}

func (s *SuiteCommit) TestParents(c *C) {
	expected := []string{
		"35e85108805c84807bc66a02d91535e1e24b38b9",
		"a5b8b09e2f8fcb0bb99d3ccb0958157b40890d69",
	}

	var output []string
	i := s.Commit.Parents()
	err := i.ForEach(func(commit *Commit) error {
		output = append(output, commit.ID().String())
		return nil
	})

	c.Assert(err, IsNil)
	c.Assert(output, DeepEquals, expected)

	i.Close()
}

func (s *SuiteCommit) TestParent(c *C) {
	commit, err := s.Commit.Parent(1)
	c.Assert(err, IsNil)
	c.Assert(commit.Hash.String(), Equals, "a5b8b09e2f8fcb0bb99d3ccb0958157b40890d69")
}

func (s *SuiteCommit) TestParentNotFound(c *C) {
	commit, err := s.Commit.Parent(42)
	c.Assert(err, Equals, ErrParentNotFound)
	c.Assert(commit, IsNil)
}

func (s *SuiteCommit) TestPatch(c *C) {
	from := s.commit(c, plumbing.NewHash("918c48b83bd081e863dbe1b80f8998f058cd8294"))
	to := s.commit(c, plumbing.NewHash("6ecf0ef2c2dffb796033e5a02219af86ec6584e5"))

	patch, err := from.Patch(to)
	c.Assert(err, IsNil)

	buf := bytes.NewBuffer(nil)
	err = patch.Encode(buf)
	c.Assert(err, IsNil)

	c.Assert(buf.String(), Equals, `diff --git a/vendor/foo.go b/vendor/foo.go
new file mode 100644
index 0000000000000000000000000000000000000000..9dea2395f5403188298c1dabe8bdafe562c491e3
--- /dev/null
+++ b/vendor/foo.go
@@ -0,0 +1,7 @@
+package main
+
+import "fmt"
+
+func main() {
+	fmt.Println("Hello, playground")
+}
`)
	c.Assert(buf.String(), Equals, patch.String())

	from = s.commit(c, plumbing.NewHash("b8e471f58bcbca63b07bda20e428190409c2db47"))
	to = s.commit(c, plumbing.NewHash("35e85108805c84807bc66a02d91535e1e24b38b9"))

	patch, err = from.Patch(to)
	c.Assert(err, IsNil)

	buf.Reset()
	err = patch.Encode(buf)
	c.Assert(err, IsNil)

	c.Assert(buf.String(), Equals, `diff --git a/CHANGELOG b/CHANGELOG
deleted file mode 100644
index d3ff53e0564a9f87d8e84b6e28e5060e517008aa..0000000000000000000000000000000000000000
--- a/CHANGELOG
+++ /dev/null
@@ -1 +0,0 @@
-Initial changelog
diff --git a/binary.jpg b/binary.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..d5c0f4ab811897cadf03aec358ae60d21f91c50d
Binary files /dev/null and b/binary.jpg differ
`)

	c.Assert(buf.String(), Equals, patch.String())
}

func (s *SuiteCommit) TestPatchContext(c *C) {
	from := s.commit(c, plumbing.NewHash("918c48b83bd081e863dbe1b80f8998f058cd8294"))
	to := s.commit(c, plumbing.NewHash("6ecf0ef2c2dffb796033e5a02219af86ec6584e5"))

	patch, err := from.PatchContext(context.Background(), to)
	c.Assert(err, IsNil)

	buf := bytes.NewBuffer(nil)
	err = patch.Encode(buf)
	c.Assert(err, IsNil)

	c.Assert(buf.String(), Equals, `diff --git a/vendor/foo.go b/vendor/foo.go
new file mode 100644
index 0000000000000000000000000000000000000000..9dea2395f5403188298c1dabe8bdafe562c491e3
--- /dev/null
+++ b/vendor/foo.go
@@ -0,0 +1,7 @@
+package main
+
+import "fmt"
+
+func main() {
+	fmt.Println("Hello, playground")
+}
`)
	c.Assert(buf.String(), Equals, patch.String())

	from = s.commit(c, plumbing.NewHash("b8e471f58bcbca63b07bda20e428190409c2db47"))
	to = s.commit(c, plumbing.NewHash("35e85108805c84807bc66a02d91535e1e24b38b9"))

	patch, err = from.PatchContext(context.Background(), to)
	c.Assert(err, IsNil)

	buf.Reset()
	err = patch.Encode(buf)
	c.Assert(err, IsNil)

	c.Assert(buf.String(), Equals, `diff --git a/CHANGELOG b/CHANGELOG
deleted file mode 100644
index d3ff53e0564a9f87d8e84b6e28e5060e517008aa..0000000000000000000000000000000000000000
--- a/CHANGELOG
+++ /dev/null
@@ -1 +0,0 @@
-Initial changelog
diff --git a/binary.jpg b/binary.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..d5c0f4ab811897cadf03aec358ae60d21f91c50d
Binary files /dev/null and b/binary.jpg differ
`)

	c.Assert(buf.String(), Equals, patch.String())
}

func (s *SuiteCommit) TestPatchContext_ToNil(c *C) {
	from := s.commit(c, plumbing.NewHash("918c48b83bd081e863dbe1b80f8998f058cd8294"))

	patch, err := from.PatchContext(context.Background(), nil)
	c.Assert(err, IsNil)

	c.Assert(len(patch.String()), Equals, 242679)
}

func (s *SuiteCommit) TestCommitEncodeDecodeIdempotent(c *C) {
	pgpsignature := `-----BEGIN PGP SIGNATURE-----

iQEcBAABAgAGBQJTZbQlAAoJEF0+sviABDDrZbQH/09PfE51KPVPlanr6q1v4/Ut
LQxfojUWiLQdg2ESJItkcuweYg+kc3HCyFejeDIBw9dpXt00rY26p05qrpnG+85b
hM1/PswpPLuBSr+oCIDj5GMC2r2iEKsfv2fJbNW8iWAXVLoWZRF8B0MfqX/YTMbm
ecorc4iXzQu7tupRihslbNkfvfciMnSDeSvzCpWAHl7h8Wj6hhqePmLm9lAYqnKp
8S5B/1SSQuEAjRZgI4IexpZoeKGVDptPHxLLS38fozsyi0QyDyzEgJxcJQVMXxVi
RUysgqjcpT8+iQM1PblGfHR4XAhuOqN5Fx06PSaFZhqvWFezJ28/CLyX5q+oIVk=
=EFTF
-----END PGP SIGNATURE-----
`

	tag := fmt.Sprintf(`object f000000000000000000000000000000000000000
type commit
tag change
tagger Foo <foo@example.local> 1695827841 -0400

change
%s
`, pgpsignature)

	ts, err := time.Parse(time.RFC3339, "2006-01-02T15:04:05-07:00")
	c.Assert(err, IsNil)
	commits := []*Commit{
		{
			Author:       Signature{Name: "Foo", Email: "foo@example.local", When: ts},
			Committer:    Signature{Name: "Bar", Email: "bar@example.local", When: ts},
			Message:      "Message\n\nFoo\nBar\nWith trailing blank lines\n\n",
			TreeHash:     plumbing.NewHash("f000000000000000000000000000000000000001"),
			ParentHashes: []plumbing.Hash{plumbing.NewHash("f000000000000000000000000000000000000002")},
			Encoding:     defaultUtf8CommitMessageEncoding,
		},
		{
			Author:    Signature{Name: "Foo", Email: "foo@example.local", When: ts},
			Committer: Signature{Name: "Bar", Email: "bar@example.local", When: ts},
			Message:   "Message\n\nFoo\nBar\nWith no trailing blank lines",
			TreeHash:  plumbing.NewHash("0000000000000000000000000000000000000003"),
			ParentHashes: []plumbing.Hash{
				plumbing.NewHash("f000000000000000000000000000000000000004"),
				plumbing.NewHash("f000000000000000000000000000000000000005"),
				plumbing.NewHash("f000000000000000000000000000000000000006"),
				plumbing.NewHash("f000000000000000000000000000000000000007"),
			},
			Encoding: MessageEncoding("ISO-8859-1"),
		},
		{
			Author:    Signature{Name: "Foo", Email: "foo@example.local", When: ts},
			Committer: Signature{Name: "Bar", Email: "bar@example.local", When: ts},
			Message:   "Testing mergetag\n\nHere, commit is not signed",
			TreeHash:  plumbing.NewHash("f000000000000000000000000000000000000001"),
			ParentHashes: []plumbing.Hash{
				plumbing.NewHash("f000000000000000000000000000000000000002"),
				plumbing.NewHash("f000000000000000000000000000000000000003"),
			},
			MergeTag: tag,
			Encoding: defaultUtf8CommitMessageEncoding,
		},
		{
			Author:    Signature{Name: "Foo", Email: "foo@example.local", When: ts},
			Committer: Signature{Name: "Bar", Email: "bar@example.local", When: ts},
			Message:   "Testing mergetag\n\nHere, commit is also signed",
			TreeHash:  plumbing.NewHash("f000000000000000000000000000000000000001"),
			ParentHashes: []plumbing.Hash{
				plumbing.NewHash("f000000000000000000000000000000000000002"),
				plumbing.NewHash("f000000000000000000000000000000000000003"),
			},
			MergeTag:     tag,
			PGPSignature: pgpsignature,
			Encoding:     defaultUtf8CommitMessageEncoding,
		},
	}
	for _, commit := range commits {
		obj := &plumbing.MemoryObject{}
		err = commit.Encode(obj)
		c.Assert(err, IsNil)
		newCommit := &Commit{}
		err = newCommit.Decode(obj)
		c.Assert(err, IsNil)
		commit.Hash = obj.Hash()
		commit.src = obj
		c.Assert(newCommit, DeepEquals, commit)
	}
}

func (s *SuiteCommit) TestFile(c *C) {
	file, err := s.Commit.File("CHANGELOG")
	c.Assert(err, IsNil)
	c.Assert(file.Name, Equals, "CHANGELOG")
}

func (s *SuiteCommit) TestNumParents(c *C) {
	c.Assert(s.Commit.NumParents(), Equals, 2)
}

func (s *SuiteCommit) TestString(c *C) {
	c.Assert(s.Commit.String(), Equals, ""+
		"commit 1669dce138d9b841a518c64b10914d88f5e488ea\n"+
		"Author: Máximo Cuadros Ortiz <mcuadros@gmail.com>\n"+
		"Date:   Tue Mar 31 13:48:14 2015 +0200\n"+
		"\n"+
		"    Merge branch 'master' of github.com:tyba/git-fixture\n"+
		"\n",
	)
}

func (s *SuiteCommit) TestStringMultiLine(c *C) {
	hash := plumbing.NewHash("e7d896db87294e33ca3202e536d4d9bb16023db3")

	f := fixtures.ByURL("https://github.com/src-d/go-git.git").One()
	sto := filesystem.NewStorage(f.DotGit(), cache.NewObjectLRUDefault())

	o, err := sto.EncodedObject(plumbing.CommitObject, hash)
	c.Assert(err, IsNil)
	commit, err := DecodeCommit(sto, o)
	c.Assert(err, IsNil)

	c.Assert(commit.String(), Equals, ""+
		"commit e7d896db87294e33ca3202e536d4d9bb16023db3\n"+
		"Author: Alberto Cortés <alberto@sourced.tech>\n"+
		"Date:   Wed Jan 27 11:13:49 2016 +0100\n"+
		"\n"+
		"    fix zlib invalid header error\n"+
		"\n"+
		"    The return value of reads to the packfile were being ignored, so zlib\n"+
		"    was getting invalid data on it read buffers.\n"+
		"\n",
	)
}

func (s *SuiteCommit) TestCommitIterNext(c *C) {
	i := s.Commit.Parents()

	commit, err := i.Next()
	c.Assert(err, IsNil)
	c.Assert(commit.ID().String(), Equals, "35e85108805c84807bc66a02d91535e1e24b38b9")

	commit, err = i.Next()
	c.Assert(err, IsNil)
	c.Assert(commit.ID().String(), Equals, "a5b8b09e2f8fcb0bb99d3ccb0958157b40890d69")

	commit, err = i.Next()
	c.Assert(err, Equals, io.EOF)
	c.Assert(commit, IsNil)
}

func (s *SuiteCommit) TestLongCommitMessageSerialization(c *C) {
	encoded := &plumbing.MemoryObject{}
	decoded := &Commit{}
	commit := *s.Commit

	longMessage := "my message: message\n\n" + strings.Repeat("test", 4096) + "\nOK"
	commit.Message = longMessage

	err := commit.Encode(encoded)
	c.Assert(err, IsNil)

	err = decoded.Decode(encoded)
	c.Assert(err, IsNil)
	c.Assert(decoded.Message, Equals, longMessage)
}

func (s *SuiteCommit) TestPGPSignatureSerialization(c *C) {
	pgpsignature := `-----BEGIN PGP SIGNATURE-----

iQEcBAABAgAGBQJTZbQlAAoJEF0+sviABDDrZbQH/09PfE51KPVPlanr6q1v4/Ut
LQxfojUWiLQdg2ESJItkcuweYg+kc3HCyFejeDIBw9dpXt00rY26p05qrpnG+85b
hM1/PswpPLuBSr+oCIDj5GMC2r2iEKsfv2fJbNW8iWAXVLoWZRF8B0MfqX/YTMbm
ecorc4iXzQu7tupRihslbNkfvfciMnSDeSvzCpWAHl7h8Wj6hhqePmLm9lAYqnKp
8S5B/1SSQuEAjRZgI4IexpZoeKGVDptPHxLLS38fozsyi0QyDyzEgJxcJQVMXxVi
RUysgqjcpT8+iQM1PblGfHR4XAhuOqN5Fx06PSaFZhqvWFezJ28/CLyX5q+oIVk=
=EFTF
-----END PGP SIGNATURE-----
`
	broken := beginpgp + "\nsome\ntrash\n" + endpgp + "text\n"

	cases := []struct {
		name         string
		pgpSignature string
		authorName   string
	}{
		{name: "pgp signature", pgpSignature: pgpsignature},
		{name: "pgp signature with leading empty line", pgpSignature: "\n" + pgpsignature},
		{name: "beginpgp marker in author name is not parsed as signature", authorName: beginpgp},
		{name: "garbage between begin and end markers round-trips", pgpSignature: broken},
	}

	for _, tc := range cases {
		c.Log(tc.name)

		commit := *s.Commit
		commit.PGPSignature = tc.pgpSignature
		if tc.authorName != "" {
			commit.Author.Name = tc.authorName
		}

		encoded := &plumbing.MemoryObject{}
		decoded := &Commit{}
		err := commit.Encode(encoded)
		c.Assert(err, IsNil)

		err = decoded.Decode(encoded)
		c.Assert(err, IsNil)
		c.Assert(decoded.PGPSignature, Equals, tc.pgpSignature)
		if tc.authorName != "" {
			c.Assert(decoded.Author.Name, Equals, tc.authorName)
		}
	}
}

func (s *SuiteCommit) TestStat(c *C) {
	aCommit := s.commit(c, plumbing.NewHash("6ecf0ef2c2dffb796033e5a02219af86ec6584e5"))
	fileStats, err := aCommit.Stats()
	c.Assert(err, IsNil)

	c.Assert(fileStats[0].Name, Equals, "vendor/foo.go")
	c.Assert(fileStats[0].Addition, Equals, 7)
	c.Assert(fileStats[0].Deletion, Equals, 0)
	c.Assert(fileStats[0].String(), Equals, " vendor/foo.go | 7 +++++++\n")

	// Stats for another commit.
	aCommit = s.commit(c, plumbing.NewHash("918c48b83bd081e863dbe1b80f8998f058cd8294"))
	fileStats, err = aCommit.Stats()
	c.Assert(err, IsNil)

	c.Assert(fileStats[0].Name, Equals, "go/example.go")
	c.Assert(fileStats[0].Addition, Equals, 142)
	c.Assert(fileStats[0].Deletion, Equals, 0)
	c.Assert(fileStats[0].String(), Equals, " go/example.go | 142 +++++++++++++++++++++++++++++++++++++++++++++++++++++\n")

	c.Assert(fileStats[1].Name, Equals, "php/crappy.php")
	c.Assert(fileStats[1].Addition, Equals, 259)
	c.Assert(fileStats[1].Deletion, Equals, 0)
	c.Assert(fileStats[1].String(), Equals, " php/crappy.php | 259 +++++++++++++++++++++++++++++++++++++++++++++++++++++\n")
}

func (s *SuiteCommit) TestVerify(c *C) {
	ts := time.Unix(1617402711, 0)
	loc, _ := time.LoadLocation("UTC")
	commit := &Commit{
		Hash:      plumbing.NewHash("1eca38290a3131d0c90709496a9b2207a872631e"),
		Author:    Signature{Name: "go-git", Email: "go-git@example.com", When: ts.In(loc)},
		Committer: Signature{Name: "go-git", Email: "go-git@example.com", When: ts.In(loc)},
		Message: `test
`,
		TreeHash:     plumbing.NewHash("52a266a58f2c028ad7de4dfd3a72fdf76b0d4e24"),
		ParentHashes: []plumbing.Hash{plumbing.NewHash("e4fbb611cd14149c7a78e9c08425f59f4b736a9a")},
		PGPSignature: `
-----BEGIN PGP SIGNATURE-----

iHUEABYKAB0WIQTMqU0ycQ3f6g3PMoWMmmmF4LuV8QUCYGebVwAKCRCMmmmF4LuV
8VtyAP9LbuXAhtK6FQqOjKybBwlV70rLcXVP24ubDuz88VVwSgD+LuObsasWq6/U
TssDKHUR2taa53bQYjkZQBpvvwOrLgc=
=YQUf
-----END PGP SIGNATURE-----
`,
	}

	armoredKeyRing := `
-----BEGIN PGP PUBLIC KEY BLOCK-----

mDMEYGeSihYJKwYBBAHaRw8BAQdAIs9A3YD/EghhAOkHDkxlUkpqYrXUXebLfmmX
+pdEK6C0D2dvLWdpdCB0ZXN0IGtleYiPBBMWCgA3FiEEzKlNMnEN3+oNzzKFjJpp
heC7lfEFAmBnkooCGyMECwkIBwUVCgkICwUWAwIBAAIeAQIXgAAKCRCMmmmF4LuV
8a3jAQCi4hSqjj6J3ch290FvQaYPGwR+EMQTMBG54t+NN6sDfgD/aZy41+0dnFKl
qM/wLW5Wr9XvwH+1zXXbuSvfxasHowq4OARgZ5KKEgorBgEEAZdVAQUBAQdAXoQz
VTYug16SisAoSrxFnOmxmFu6efYgCAwXu0ZuvzsDAQgHiHgEGBYKACAWIQTMqU0y
cQ3f6g3PMoWMmmmF4LuV8QUCYGeSigIbDAAKCRCMmmmF4LuV8Q4QAQCKW5FnEdWW
lHYKeByw3JugnlZ0U3V/R20bCwDglst5UQEAtkN2iZkHtkPly9xapsfNqnrt2gTt
YIefGtzXfldDxg4=
=Psht
-----END PGP PUBLIC KEY BLOCK-----
`

	e, err := commit.Verify(armoredKeyRing)
	c.Assert(err, IsNil)

	_, ok := e.Identities["go-git test key"]
	c.Assert(ok, Equals, true)
}

func (s *SuiteCommit) TestPatchCancel(c *C) {
	from := s.commit(c, plumbing.NewHash("918c48b83bd081e863dbe1b80f8998f058cd8294"))
	to := s.commit(c, plumbing.NewHash("6ecf0ef2c2dffb796033e5a02219af86ec6584e5"))

	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	patch, err := from.PatchContext(ctx, to)
	c.Assert(patch, IsNil)
	c.Assert(err, ErrorMatches, "operation canceled")

}

func (s *SuiteCommit) TestDecodeRequiresTreeFirst(c *C) {
	const (
		validTree   = "eba74343e2f15d62adedfd8c883ee0262b5c8021"
		validParent = "35e85108805c84807bc66a02d91535e1e24b38b9"
		validIdent  = "Foo <foo@example.local> 1427802494 +0200"
	)

	cases := []struct {
		name string
		raw  string
	}{
		{
			name: "missing tree",
			raw:  "author " + validIdent + "\ncommitter " + validIdent + "\n\nmsg\n",
		},
		{
			name: "parent before tree",
			raw:  "parent " + validParent + "\ntree " + validTree + "\nauthor " + validIdent + "\ncommitter " + validIdent + "\n\nmsg\n",
		},
		{
			name: "extra header before tree",
			raw:  "x-extra hi\ntree " + validTree + "\nauthor " + validIdent + "\ncommitter " + validIdent + "\n\nmsg\n",
		},
		{
			name: "empty",
			raw:  "",
		},
		{
			name: "non-hex tree value",
			raw:  "tree zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz\nauthor " + validIdent + "\ncommitter " + validIdent + "\n\nmsg\n",
		},
		{
			name: "tree value too short",
			raw:  "tree abcd\nauthor " + validIdent + "\ncommitter " + validIdent + "\n\nmsg\n",
		},
		{
			name: "tree value too long",
			raw:  "tree " + validTree + "00\nauthor " + validIdent + "\ncommitter " + validIdent + "\n\nmsg\n",
		},
		{
			name: "tree value missing",
			raw:  "tree\nauthor " + validIdent + "\ncommitter " + validIdent + "\n\nmsg\n",
		},
		{
			name: "non-hex parent value",
			raw:  "tree " + validTree + "\nparent zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz\nauthor " + validIdent + "\ncommitter " + validIdent + "\n\nmsg\n",
		},
		{
			name: "parent value too short",
			raw:  "tree " + validTree + "\nparent abcd\nauthor " + validIdent + "\ncommitter " + validIdent + "\n\nmsg\n",
		},
	}

	for _, tc := range cases {
		c.Log(tc.name)
		obj := &plumbing.MemoryObject{}
		obj.SetType(plumbing.CommitObject)
		_, err := obj.Write([]byte(tc.raw))
		c.Assert(err, IsNil)

		err = (&Commit{}).Decode(obj)
		c.Assert(errors.Is(err, ErrMalformedCommit), Equals, true)
	}
}

func (s *SuiteCommit) TestDecodeFirstOccurrenceWins(c *C) {
	const (
		treeA       = "eba74343e2f15d62adedfd8c883ee0262b5c8021"
		treeB       = "0000000000000000000000000000000000000001"
		parentA     = "35e85108805c84807bc66a02d91535e1e24b38b9"
		parentB     = "a5b8b09e2f8fcb0bb99d3ccb0958157b40890d69"
		parentC     = "0000000000000000000000000000000000000002"
		identA      = "Alice <alice@example.local> 1427802494 +0200"
		identB      = "Bob <bob@example.local> 1427802495 +0200"
		identAuthor = "Author Name <author@example.local> 1500000000 +0000"
		identCommit = "Commit Name <commit@example.local> 1500000001 +0000"
	)

	cases := []struct {
		name   string
		raw    string
		assert func(*Commit)
	}{
		{
			name: "duplicate tree drops the second",
			raw: "tree " + treeA + "\ntree " + treeB +
				"\nauthor " + identAuthor + "\ncommitter " + identCommit + "\n\nmsg\n",
			assert: func(commit *Commit) {
				c.Assert(commit.TreeHash.String(), Equals, treeA)
			},
		},
		{
			name: "duplicate author drops the second",
			raw: "tree " + treeA + "\nauthor " + identA + "\nauthor " + identB +
				"\ncommitter " + identCommit + "\n\nmsg\n",
			assert: func(commit *Commit) {
				c.Assert(commit.Author.Name, Equals, "Alice")
			},
		},
		{
			name: "duplicate committer drops the second",
			raw: "tree " + treeA + "\nauthor " + identAuthor +
				"\ncommitter " + identA + "\ncommitter " + identB + "\n\nmsg\n",
			assert: func(commit *Commit) {
				c.Assert(commit.Committer.Name, Equals, "Alice")
			},
		},
		{
			name: "parent after author is dropped",
			raw: "tree " + treeA + "\nparent " + parentA +
				"\nauthor " + identAuthor + "\nparent " + parentC +
				"\ncommitter " + identCommit + "\n\nmsg\n",
			assert: func(commit *Commit) {
				c.Assert(commit.ParentHashes, DeepEquals, []plumbing.Hash{plumbing.NewHash(parentA)})
			},
		},
		{
			name: "parent interleaved with extras is dropped",
			raw: "tree " + treeA + "\nparent " + parentA + "\nparent " + parentB +
				"\nx-other thing\nparent " + parentC +
				"\nauthor " + identAuthor + "\ncommitter " + identCommit + "\n\nmsg\n",
			assert: func(commit *Commit) {
				c.Assert(commit.ParentHashes, DeepEquals, []plumbing.Hash{
					plumbing.NewHash(parentA),
					plumbing.NewHash(parentB),
				})
			},
		},
		{
			name: "missing committer is allowed (zero-valued)",
			raw: "tree " + treeA + "\nauthor " + identAuthor +
				"\n\nmsg\n",
			assert: func(commit *Commit) {
				c.Assert(commit.Author.Name, Equals, "Author Name")
				c.Assert(commit.Committer.Name, Equals, "")
			},
		},
		{
			name: "encoding between author and committer drops committer",
			raw: "tree " + treeA + "\nauthor " + identAuthor +
				"\nencoding latin-1\ncommitter " + identCommit + "\n\nmsg\n",
			assert: func(commit *Commit) {
				c.Assert(commit.Author.Name, Equals, "Author Name")
				c.Assert(commit.Encoding, Equals, MessageEncoding("latin-1"))
				// committer was not at its canonical position
				// (immediately after author) so it is dropped, matching
				// upstream's parse_commit_date returning 0 and the
				// subsequent standard_header_field filter.
				c.Assert(commit.Committer.Name, Equals, "")
			},
		},
		{
			name: "author out of canonical position is dropped",
			raw: "tree " + treeA + "\nencoding latin-1\nauthor " + identAuthor +
				"\ncommitter " + identCommit + "\n\nmsg\n",
			assert: func(commit *Commit) {
				c.Assert(commit.Encoding, Equals, MessageEncoding("latin-1"))
				c.Assert(commit.Author.Name, Equals, "")
				c.Assert(commit.Committer.Name, Equals, "")
			},
		},
		{
			// Multiple gpgsig headers concatenate into a single
			// signature buffer, mirroring upstream's
			// parse_buffer_signed_by_header (commit.c:1186).
			name: "multiple gpgsig headers concatenate",
			raw: "tree " + treeA + "\nauthor " + identAuthor +
				"\ncommitter " + identCommit +
				"\ngpgsig firstline\n morefirst\ngpgsig secondline\n moresecond\n\nmsg\n",
			assert: func(commit *Commit) {
				c.Assert(commit.PGPSignature, Equals, "firstline\nmorefirst\nsecondline\nmoresecond\n")
			},
		},
		{
			name: "gpgsig-sha256 headers are not exposed as extras",
			raw: "tree " + treeA + "\nauthor " + identAuthor +
				"\ncommitter " + identCommit +
				"\ngpgsig-sha256 firstline\n morefirst\ngpgsig-sha256 secondline\n moresecond\n\nmsg\n",
			assert: func(commit *Commit) {
				c.Assert(commit.PGPSignature, Equals, "")
				c.Assert(len(commit.ExtraHeaders), Equals, 0)
			},
		},
	}

	for _, tc := range cases {
		c.Log(tc.name)
		obj := &plumbing.MemoryObject{}
		obj.SetType(plumbing.CommitObject)
		_, err := obj.Write([]byte(tc.raw))
		c.Assert(err, IsNil)

		commit := &Commit{}
		c.Assert(commit.Decode(obj), IsNil)
		tc.assert(commit)
	}
}

func (s *SuiteCommit) TestMalformedHeader(c *C) {
	encoded := &plumbing.MemoryObject{}
	decoded := &Commit{}
	commit := *s.Commit

	commit.PGPSignature = "\n"
	commit.Author.Name = "\n"
	commit.Author.Email = "\n"
	commit.Committer.Name = "\n"
	commit.Committer.Email = "\n"

	err := commit.Encode(encoded)
	c.Assert(err, IsNil)

	err = decoded.Decode(encoded)
	c.Assert(err, IsNil)
}

func (s *SuiteCommit) TestEncodeWithoutSignature(c *C) {
	tests := []struct {
		name      string
		commitRaw string
		mutate    func(*Commit)
		expected  string
	}{
		{
			name: "commit without signature",
			commitRaw: `tree eba74343e2f15d62adedfd8c883ee0262b5c8021
parent 35e85108805c84807bc66a02d91535e1e24b38b9
parent a5b8b09e2f8fcb0bb99d3ccb0958157b40890d69
author Máximo Cuadros Ortiz <mcuadros@gmail.com> 1427802494 +0200
committer Máximo Cuadros Ortiz <mcuadros@gmail.com> 1427802494 +0200

Merge branch 'master' of github.com:tyba/git-fixture
`,
			expected: `tree eba74343e2f15d62adedfd8c883ee0262b5c8021
parent 35e85108805c84807bc66a02d91535e1e24b38b9
parent a5b8b09e2f8fcb0bb99d3ccb0958157b40890d69
author Máximo Cuadros Ortiz <mcuadros@gmail.com> 1427802494 +0200
committer Máximo Cuadros Ortiz <mcuadros@gmail.com> 1427802494 +0200

Merge branch 'master' of github.com:tyba/git-fixture
`,
		},
		{
			name: "commit with change-id and gpgsig",
			commitRaw: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700
change-id wxmuynokkzxmuwxwvnnpnptoyuypknwv
gpgsig -----BEGIN PGP SIGNATURE-----
 iHUEABMIAB0WIQSZpnSpGKbQbDaLe5iiNQl48cTY5gUCaJ91XQAKCRCiNQl48cTY
 5vCYAP9Sf1yV9oUviRIxEA+4rsGIx0hI6kqFajJ/3TtBjyCTggD+PFnKOxdXeFL2
 GLwcCzFIsmQmkLxuLypsg+vueDSLpsM=
 =VucY
 -----END PGP SIGNATURE-----

initial commit

Change-Id: I6a6a696432d51cbff02d53234ccaca6b151afc34
`,
			expected: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700
change-id wxmuynokkzxmuwxwvnnpnptoyuypknwv

initial commit

Change-Id: I6a6a696432d51cbff02d53234ccaca6b151afc34
`,
		},
		{
			name: "gpgsig-sha256",
			commitRaw: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700
change-id wxmuynokkzxmuwxwvnnpnptoyuypknwv
gpgsig-sha256 -----BEGIN PGP SIGNATURE-----
 iHUEABMIAB0WIQSZpnSpGKbQbDaLe5iiNQl48cTY5gUCaJ91XQAKCRCiNQl48cTY
 5vCYAP9Sf1yV9oUviRIxEA+4rsGIx0hI6kqFajJ/3TtBjyCTggD+PFnKOxdXeFL2
 GLwcCzFIsmQmkLxuLypsg+vueDSLpsM=
 =VucY
 -----END PGP SIGNATURE-----

initial commit

Change-Id: I6a6a696432d51cbff02d53234ccaca6b151afc34
`,
			expected: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700
change-id wxmuynokkzxmuwxwvnnpnptoyuypknwv

initial commit

Change-Id: I6a6a696432d51cbff02d53234ccaca6b151afc34
`,
		},
		{
			name: "gpgsig + gpgsig-sha256",
			commitRaw: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700
change-id wxmuynokkzxmuwxwvnnpnptoyuypknwv
gpgsig -----BEGIN PGP SIGNATURE-----
 iHUEABMIAB0WIQSZpnSpGKbQbDaLe5iiNQl48cTY5gUCaJ91XQAKCRCiNQl48cTY
 GLwcCzFIsmQmkLxuLypsg+vueDSLpsM=
 =VucY
 -----END PGP SIGNATURE-----
gpgsig-sha256 -----BEGIN PGP SIGNATURE-----
 iHUEABMIAB0WIQSZpnSpGKbQbDaLe5iiNQl48cTY5gUCaJ91XQAKCRCiNQl48cTY
 =VucY
 -----END PGP SIGNATURE-----

initial commit

Change-Id: I6a6a696432d51cbff02d53234ccaca6b151afc34
`,
			expected: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700
change-id wxmuynokkzxmuwxwvnnpnptoyuypknwv

initial commit

Change-Id: I6a6a696432d51cbff02d53234ccaca6b151afc34
`,
		},
		{
			name: "non-canonical gpgsig-prefixed extra header is preserved",
			commitRaw: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700
gpgsig-key-id ABCDEF0123456789
gpgsig -----BEGIN PGP SIGNATURE-----
 sigline1
 -----END PGP SIGNATURE-----

initial commit
`,
			expected: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700
gpgsig-key-id ABCDEF0123456789

initial commit
`,
		},
		{
			name: "raw bytes preserved when only PGPSignature is mutated",
			commitRaw: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700
gpgsig -----BEGIN PGP SIGNATURE-----
 sigline1
 -----END PGP SIGNATURE-----
gpgsig-sha256 -----BEGIN PGP SIGNATURE-----
 sha256line1
 -----END PGP SIGNATURE-----

initial commit
`,
			mutate: func(c *Commit) {
				c.PGPSignature = "different signature value"
			},
			expected: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700

initial commit
`,
		},
		{
			name: "timezone-only change triggers struct-encode",
			commitRaw: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700
gpgsig -----BEGIN PGP SIGNATURE-----
 sigline1
 -----END PGP SIGNATURE-----

initial commit
`,
			mutate: func(c *Commit) {
				tz := time.FixedZone("CEST", 2*60*60)
				c.Author.When = c.Author.When.In(tz)
				c.Committer.When = c.Committer.When.In(tz)
			},
			expected: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 +0200
committer John Doe <john.doe@example.com> 1755280730 +0200

initial commit
`,
		},
		{
			name: "field mutation triggers struct-encode",
			commitRaw: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700
gpgsig -----BEGIN PGP SIGNATURE-----
 sigline1
 -----END PGP SIGNATURE-----

initial commit
`,
			mutate: func(c *Commit) {
				c.Message = "rewritten message\n"
			},
			expected: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700

rewritten message
`,
		},
		{
			name: "standard-keyed ExtraHeaders are dropped on encode",
			commitRaw: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700

initial commit
`,
			mutate: func(c *Commit) {
				c.Message = "rewritten message\n"
				c.ExtraHeaders = []ExtraHeader{
					{Key: "tree", Value: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"},
					{Key: "parent", Value: "feedfacefeedfacefeedfacefeedfacefeedface"},
					{Key: "author", Value: "Fake <fake@example.com> 0 +0000"},
					{Key: "committer", Value: "Fake <fake@example.com> 0 +0000"},
					{Key: "encoding", Value: "fake-enc"},
					{Key: "mergetag", Value: "fake mergetag"},
					{Key: "gpgsig", Value: "fake-sig"},
					{Key: "gpgsig-sha256", Value: "fake-sig-256"},
					{Key: "x-real-extra", Value: "kept"},
				}
			},
			expected: `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700
x-real-extra kept

rewritten message
`,
		},
	}

	for _, tc := range tests {
		c.Log(tc.name)

		obj := &plumbing.MemoryObject{}
		obj.SetType(plumbing.CommitObject)
		obj.Write([]byte(tc.commitRaw))

		commit, err := DecodeCommit(s.Storer, obj)
		c.Assert(err, IsNil)
		if tc.mutate != nil {
			tc.mutate(commit)
		}

		encoded := &plumbing.MemoryObject{}
		err = commit.EncodeWithoutSignature(encoded)
		c.Assert(err, IsNil)

		er, err := encoded.Reader()
		c.Assert(err, IsNil)

		payload, err := io.ReadAll(er)
		c.Assert(err, IsNil)

		c.Assert(string(payload), Equals, tc.expected)
	}
}

func (s *SuiteCommit) TestEncodeExtraHeaders(c *C) {
	object := &plumbing.MemoryObject{}
	object.SetType(plumbing.CommitObject)
	object.Write([]byte(`tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700
continuedheader to be
 continued
continuedheader to be
 continued
 on
 more than
 a single line
simpleflag
 value no key

initial commit
`))

	commit, err := DecodeCommit(s.Storer, object)
	c.Assert(err, IsNil)

	c.Assert(commit.ExtraHeaders, DeepEquals, []ExtraHeader{
		ExtraHeader{
			Key:   "continuedheader",
			Value: "to be\ncontinued",
		},
		ExtraHeader{
			Key:   "continuedheader",
			Value: "to be\ncontinued\non\nmore than\na single line",
		},
		ExtraHeader{
			Key:   "simpleflag",
			Value: "",
		},
		ExtraHeader{
			Key:   "",
			Value: "value no key",
		},
	})

	// Similar to TestString since no signature
	encoded := &plumbing.MemoryObject{}
	err = commit.EncodeWithoutSignature(encoded)
	er, err := encoded.Reader()
	c.Assert(err, IsNil)
	payload, err := io.ReadAll(er)
	c.Assert(err, IsNil)

	c.Assert(string(payload), Equals, `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
author John Doe <john.doe@example.com> 1755280730 -0700
committer John Doe <john.doe@example.com> 1755280730 -0700
continuedheader to be
 continued
continuedheader to be
 continued
 on
 more than
 a single line
simpleflag
 value no key

initial commit
`)
}

func (s *SuiteCommit) TestLess(c *C) {
	when1 := time.Now()
	when2 := when1.Add(time.Hour)

	hash1 := plumbing.NewHash("1669dce138d9b841a518c64b10914d88f5e488ea")
	hash2 := plumbing.NewHash("2669dce138d9b841a518c64b10914d88f5e488ea")

	commitLessTests := []struct {
		Committer1When, Committer2When time.Time
		Author1When, Author2When       time.Time
		Hash1, Hash2                   plumbing.Hash
		Exp                            bool
	}{
		{when1, when1, when1, when1, hash1, hash2, true},
		{when1, when1, when1, when1, hash2, hash1, false},
		{when1, when1, when1, when2, hash1, hash2, true},
		{when1, when1, when1, when2, hash2, hash1, true},
		{when1, when1, when2, when1, hash1, hash2, false},
		{when1, when1, when2, when1, hash2, hash1, false},
		{when1, when1, when2, when2, hash1, hash2, true},
		{when1, when1, when2, when2, hash2, hash1, false},
		{when1, when2, when1, when1, hash1, hash2, true},
		{when1, when2, when1, when1, hash2, hash1, true},
		{when1, when2, when1, when2, hash1, hash2, true},
		{when1, when2, when1, when2, hash2, hash1, true},
		{when1, when2, when2, when1, hash1, hash2, true},
		{when1, when2, when2, when1, hash2, hash1, true},
		{when1, when2, when2, when2, hash1, hash2, true},
		{when1, when2, when2, when2, hash2, hash1, true},
		{when2, when1, when1, when1, hash1, hash2, false},
		{when2, when1, when1, when1, hash2, hash1, false},
		{when2, when1, when1, when2, hash1, hash2, false},
		{when2, when1, when1, when2, hash2, hash1, false},
		{when2, when1, when2, when1, hash1, hash2, false},
		{when2, when1, when2, when1, hash2, hash1, false},
		{when2, when1, when2, when2, hash1, hash2, false},
		{when2, when1, when2, when2, hash2, hash1, false},
		{when2, when2, when1, when1, hash1, hash2, true},
		{when2, when2, when1, when1, hash2, hash1, false},
		{when2, when2, when1, when2, hash1, hash2, true},
		{when2, when2, when1, when2, hash2, hash1, true},
		{when2, when2, when2, when1, hash1, hash2, false},
		{when2, when2, when2, when1, hash2, hash1, false},
		{when2, when2, when2, when2, hash1, hash2, true},
		{when2, when2, when2, when2, hash2, hash1, false},
	}

	for _, t := range commitLessTests {
		commit1 := &Commit{
			Hash: t.Hash1,
			Author: Signature{
				When: t.Author1When,
			},
			Committer: Signature{
				When: t.Committer1When,
			},
		}
		commit2 := &Commit{
			Hash: t.Hash2,
			Author: Signature{
				When: t.Author2When,
			},
			Committer: Signature{
				When: t.Committer2When,
			},
		}
		c.Assert(commit1.Less(commit2), Equals, t.Exp)
	}
}
