package knotfeed

import (
	"fmt"
	"reflect"
	"strings"
	"testing"

	"tangled.org/core/tapc"
)

const (
	legacyNewSha = "bcfa6359b037bc411e9626260cc3dd9a28408d2c"
	legacyOldSha = "9eab6c4f29bc9e609ec52888cd6a4ef716b567c9"
	zeroSha1     = "0000000000000000000000000000000000000000"
)

const legacyRefFrame = `{
	"rkey": "3mug",
	"nsid": "sh.tangled.git.refUpdate",
	"created": 1788245839553422000,
	"event": {
		"$type": "sh.tangled.git.refUpdate",
		"ref": "refs/heads/iso16283-low-frequency-corner",
		"oldSha": "9eab6c4f29bc9e609ec52888cd6a4ef716b567c9",
		"newSha": "bcfa6359b037bc411e9626260cc3dd9a28408d2c",
		"committerDid": "did:plc:jder4wkjnwh6mzhrx4n5ebti",
		"ownerDid": "did:plc:jder4wkjnwh6mzhrx4n5ebti",
		"repo": "did:plc:5i6o2hrw2viwpxgicwad7f2h",
		"pushOptions": ["skip-ci"],
		"changedFiles": ["tests/building/measurement/test_low_frequency.py"],
		"meta": {"isDefaultRef": false, "commitCount": {"byEmail": [{"email": "mail@jmrp.io", "count": 1}]}}
	}
}`

func refFrame(ref, repo, oldSha, newSha string) string {
	return fmt.Sprintf(
		`{"nsid":"sh.tangled.git.refUpdate","created":11,`+
			`"event":{"ref":%q,"repo":%q,"oldSha":%q,"newSha":%q}}`,
		ref, repo, oldSha, newSha)
}

func sha(t *testing.T, hex string) ObjectID {
	t.Helper()
	id, ok := ParseObjectID(hex)
	if !ok {
		t.Fatalf("ParseObjectID(%q)", hex)
	}
	return id
}

func refOp(t *testing.T, refname, oldSha string, rec RefRecord) RecordOp {
	t.Helper()
	rkey, ok := EscapeRefname(refname)
	if !ok {
		t.Fatalf("EscapeRefname(%q)", refname)
	}
	op := RecordOp{
		Action:     tapc.RecordDeleteAction,
		Collection: GitRefCollection,
		Rkey:       rkey,
		Prior:      ParsePriorSha(oldSha),
	}
	if rec.Sha.IsZero() {
		return op
	}
	record, err := encodeRefRecord(rec)
	if err != nil {
		t.Fatalf("encodeRefRecord: %v", err)
	}
	op.Action, op.Bytes = tapc.RecordCreateAction, record
	return op
}

func TestLegacyFrameReadsAsCommit(t *testing.T) {
	for _, tt := range []struct {
		name  string
		frame string
		want  Commit
	}{
		{
			name:  "push",
			frame: legacyRefFrame,
			want: Commit{
				Repo: "did:plc:5i6o2hrw2viwpxgicwad7f2h",
				Seq:  1788245839553422000,
				Records: []RecordOp{refOp(t, "refs/heads/iso16283-low-frequency-corner", legacyOldSha,
					RefRecord{
						Sha:         sha(t, legacyNewSha),
						Editor:      "did:plc:jder4wkjnwh6mzhrx4n5ebti",
						PushOptions: []string{"skip-ci"},
					})},
			},
		},
		{
			name:  "deleted branch",
			frame: refFrame("refs/heads/gone", "did:plc:scallop", legacyOldSha, zeroSha1),
			want: Commit{
				Repo:    "did:plc:scallop",
				Seq:     11,
				Records: []RecordOp{refOp(t, "refs/heads/gone", legacyOldSha, RefRecord{})},
			},
		},
		{
			name: "deleted branch in a sha256 repo",
			frame: refFrame("refs/heads/gone", "did:plc:scallop",
				strings.Repeat("ab", 32), strings.Repeat("0", 64)),
			want: Commit{
				Repo:    "did:plc:scallop",
				Seq:     11,
				Records: []RecordOp{refOp(t, "refs/heads/gone", strings.Repeat("ab", 32), RefRecord{})},
			},
		},
		{
			name:  "brand new branch",
			frame: refFrame("refs/heads/new", "did:plc:scallop", zeroSha1, legacyNewSha),
			want: Commit{
				Repo: "did:plc:scallop",
				Seq:  11,
				Records: []RecordOp{refOp(t, "refs/heads/new", zeroSha1,
					RefRecord{Sha: sha(t, legacyNewSha), Editor: "did:plc:scallop"})},
			},
		},
		{
			name:  "push that left oldSha empty",
			frame: refFrame("refs/heads/new", "did:plc:scallop", "", legacyNewSha),
			want: Commit{
				Repo: "did:plc:scallop",
				Seq:  11,
				Records: []RecordOp{refOp(t, "refs/heads/new", "",
					RefRecord{Sha: sha(t, legacyNewSha), Editor: "did:plc:scallop"})},
			},
		},
		{
			name: "push from a pre-1.14 knot, which names the repo repoDid",
			frame: `{"nsid":"sh.tangled.git.refUpdate","created":11,"event":{` +
				`"ref":"refs/heads/main","ownerDid":"did:plc:clam","repoDid":"did:plc:scallop",` +
				`"repoName":"shell","committerDid":"did:plc:clam",` +
				`"oldSha":"` + legacyOldSha + `","newSha":"` + legacyNewSha + `"}}`,
			want: Commit{
				Repo: "did:plc:scallop",
				Seq:  11,
				Records: []RecordOp{refOp(t, "refs/heads/main", legacyOldSha,
					RefRecord{Sha: sha(t, legacyNewSha), Editor: "did:plc:clam"})},
			},
		},
		{
			name: "push with an empty committerDid",
			frame: `{"nsid":"sh.tangled.git.refUpdate","created":11,` +
				`"event":{"ref":"refs/heads/main","repo":"did:plc:scallop","committerDid":"",` +
				`"oldSha":"` + legacyOldSha + `","newSha":"` + legacyNewSha + `"}}`,
			want: Commit{
				Repo: "did:plc:scallop",
				Seq:  11,
				Records: []RecordOp{refOp(t, "refs/heads/main", legacyOldSha,
					RefRecord{Sha: sha(t, legacyNewSha), Editor: "did:plc:scallop"})},
			},
		},
		{
			name: "collaborator removal",
			frame: `{"nsid":"sh.tangled.repo.collaboratorUpdate","created":9,
				"event":{"op":"remove","subject":"did:plc:olaren","repo":"did:plc:scallop"}}`,
			want: Commit{
				Repo: "did:plc:scallop",
				Seq:  9,
				Records: []RecordOp{{
					Action:     tapc.RecordDeleteAction,
					Collection: collaboratorInviteCollection,
					Rkey:       "did:plc:olaren",
				}},
			},
		},
		{
			name: "knot member update",
			frame: `{"nsid":"sh.tangled.knot.memberUpdate","created":11,
				"event":{"op":"add","subject":"did:plc:limpet"}}`,
			want: Commit{Seq: 11},
		},
		{
			name:  "ref update with an empty sha",
			frame: refFrame("refs/heads/main", "did:plc:scallop", legacyOldSha, ""),
			want:  Commit{Seq: 11},
		},
		{
			name:  "ref update with an empty refname",
			frame: refFrame("", "did:plc:scallop", legacyOldSha, legacyNewSha),
			want:  Commit{Seq: 11},
		},
		{
			name:  "new sha with shell junk in it",
			frame: refFrame("refs/heads/main", "did:plc:scallop", legacyOldSha, "HEAD~1; rm -rf /"),
			want:  Commit{Seq: 11},
		},
	} {
		t.Run(tt.name, func(t *testing.T) {
			msg, err := decodeLegacy([]byte(tt.frame), nil)
			if err != nil {
				t.Fatalf("decodeLegacy: %v", err)
			}
			if msg.Type != TypeCommit || msg.Commit == nil {
				t.Fatalf("message type = %q, want a commit", msg.Type)
			}
			if !reflect.DeepEqual(*msg.Commit, tt.want) {
				t.Errorf("commit = %+v, want %+v", *msg.Commit, tt.want)
			}
		})
	}
}

func TestLegacyFrameWithoutCursorErrorsOut(t *testing.T) {
	if _, err := decodeLegacy([]byte(`{"nsid":"sh.tangled.git.refUpdate"}`), nil); err == nil {
		t.Error("frame without a created stamp decoded anyway")
	}
}

func TestOnlyObjectIdReadsAsPriorSha(t *testing.T) {
	for _, reported := range []string{"", "def456", "HEAD", strings.Repeat("zz", 20), zeroSha1 + "0"} {
		if id, known := ParsePriorSha(reported).Sha(); known {
			t.Errorf("ParsePriorSha(%q) = %q, want an unknown prior", reported, id)
		}
	}
	if _, known := (RecordOp{}).Prior.Sha(); known {
		t.Error("zero-value RecordOp came back with a known prior sha")
	}

	hex := strings.Repeat("ab", 32)
	if got, known := ParsePriorSha(hex).Sha(); !known || got.String() != hex {
		t.Errorf("ParsePriorSha(%q) = (%q, %v), want the sha back", hex, got, known)
	}
	if got, known := ParsePriorSha(zeroSha1).Sha(); !known || !got.IsZero() {
		t.Errorf("ParsePriorSha(%q) = (%q, %v), want a known absence", zeroSha1, got, known)
	}
}

func TestParseObjectIDTakesOnlyRealObjectIds(t *testing.T) {
	for _, hex := range []string{
		"", "def456", strings.Repeat("ab", 21), strings.Repeat("zz", 20), "HEAD~1; rm -rf /",
	} {
		if id, ok := ParseObjectID(hex); ok {
			t.Errorf("ParseObjectID(%q) = %q, want a refusal", hex, id)
		}
	}
	for _, hex := range []string{zeroSha1, strings.Repeat("0", 64)} {
		id, ok := ParseObjectID(hex)
		if !ok || !id.IsZero() {
			t.Errorf("ParseObjectID(%q) = (%q, %v), want the null object id", hex, id, ok)
		}
	}
	for _, hex := range []string{legacyNewSha, strings.Repeat("AB", 20), strings.Repeat("cD", 32)} {
		id, ok := ParseObjectID(hex)
		if !ok || id.String() != hex {
			t.Errorf("ParseObjectID(%q) = (%q, %v), want the sha back", hex, id, ok)
		}
	}
}

func TestLegacyFrameWithUnusableFieldsIsRefused(t *testing.T) {
	for _, tt := range []struct {
		name  string
		frame string
	}{
		{"nsid that isn't an nsid", `{"nsid":"not an nsid","created":11}`},
		{"repo that isn't a did", refFrame("refs/heads/main", "scallop", legacyOldSha, legacyNewSha)},
		{"collaborator subject that isn't a did", `{"nsid":"sh.tangled.repo.collaboratorUpdate","created":9,
			"event":{"op":"remove","subject":"olaren","repo":"did:plc:scallop"}}`},
	} {
		t.Run(tt.name, func(t *testing.T) {
			if _, err := decodeLegacy([]byte(tt.frame), nil); err == nil {
				t.Error("frame decoded anyway")
			}
		})
	}
}
