package gitutil

import (
	"archive/zip"
	"bytes"
	"context"
	"mime"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
	"unicode/utf8"

	"github.com/go-git/go-git/v5/plumbing"
	"github.com/samber/lo"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

const testArchiveEndpoint = "https://knot.nel.pet/xrpc/sh.tangled.repo.archive"

func TestParseArchiveParams(t *testing.T) {
	cases := []struct {
		name         string
		query        url.Values
		repo         RepoName
		want         ArchiveParams
		wantStem     ArchivePrefix
		wantFilename string
		wantType     string
		wantErr      string
	}{
		{"all empty", url.Values{}, "", ArchiveParams{Format: ArchiveTarGz}, "", "", "", ""},
		{"tar.gz", url.Values{"format": {"tar.gz"}}, "", ArchiveParams{Format: ArchiveTarGz}, "", "", "", ""},
		{"zip", url.Values{"format": {"zip"}}, "", ArchiveParams{Format: ArchiveZip}, "", "", "", ""},
		{
			"every param",
			url.Values{"ref": {"refs/tags/v1.0.0"}, "format": {"zip"}, "prefix": {"/kelp/"}},
			"", ArchiveParams{Rev: "refs/tags/v1.0.0", Format: ArchiveZip, Prefix: "kelp"},
			"kelp", "squid-v1.0.0.zip", "application/zip", "",
		},

		{"branch", url.Values{"ref": {"main"}}, "", ArchiveParams{Rev: "main", Format: ArchiveTarGz}, "squid-main", "squid-main.tar.gz", "application/gzip", ""},
		{"full ref", url.Values{"ref": {"refs/heads/feat/uni"}}, "", ArchiveParams{Rev: "refs/heads/feat/uni", Format: ArchiveTarGz}, "squid-feat-uni", "squid-feat-uni.tar.gz", "application/gzip", ""},
		{"head", url.Values{"ref": {"HEAD"}}, "", ArchiveParams{Rev: "HEAD", Format: ArchiveTarGz}, "squid-HEAD", "", "", ""},
		{"trailing slash", url.Values{"ref": {"refs/heads/main/"}}, "", ArchiveParams{Rev: "refs/heads/main/", Format: ArchiveTarGz}, "squid-main-", "", "", ""},
		{"traversal in a ref", url.Values{"ref": {"../../etc"}}, "", ArchiveParams{Rev: "../../etc", Format: ArchiveTarGz}, "squid-..-..-etc", "", "", ""},
		{"windows separator in a ref", url.Values{"ref": {`feat\uni`}}, "", ArchiveParams{Rev: `feat\uni`, Format: ArchiveTarGz}, "squid-feat-uni", "", "", ""},
		{"slash in a repo name", url.Values{"ref": {"main"}}, "kelp/limpet", ArchiveParams{Rev: "main", Format: ArchiveTarGz}, "kelp-limpet-main", "kelp-limpet-main.tar.gz", "application/gzip", ""},
		{"quote in a repo name", url.Values{"ref": {"main"}}, `squid-a"b`, ArchiveParams{Rev: "main", Format: ArchiveTarGz}, `squid-a"b-main`, `squid-a"b-main.tar.gz`, "application/gzip", ""},
		{"non-ascii repo name", url.Values{"ref": {"main"}, "format": {"zip"}}, "squid-über", ArchiveParams{Rev: "main", Format: ArchiveZip}, "squid-über-main", "squid-über-main.zip", "application/zip", ""},

		{"bare slash prefix", url.Values{"prefix": {"/"}}, "", ArchiveParams{Format: ArchiveTarGz}, "", "", "", ""},
		{"did prefix", url.Values{"prefix": {"did:plc:boltless"}}, "", ArchiveParams{Format: ArchiveTarGz, Prefix: "did:plc:boltless"}, "", "", "", ""},
		{"nested prefix", url.Values{"prefix": {"squid/main"}}, "", ArchiveParams{Format: ArchiveTarGz, Prefix: "squid/main"}, "", "", "", ""},
		{"prefix wrapped in slashes", url.Values{"prefix": {"/squid/main/"}}, "", ArchiveParams{Format: ArchiveTarGz, Prefix: "squid/main"}, "", "", "", ""},
		{"redundant prefix segments", url.Values{"prefix": {"squid/../limpet"}}, "", ArchiveParams{Format: ArchiveTarGz, Prefix: "limpet"}, "", "", "", ""},
		{"space in a prefix", url.Values{"prefix": {"squid main"}}, "", ArchiveParams{Format: ArchiveTarGz, Prefix: "squid main"}, "", "", "", ""},

		{"unsupported format", url.Values{"format": {"tar"}}, "", ArchiveParams{}, "", "", "", "only tar.gz and zip formats are supported"},
		{"space in a ref", url.Values{"ref": {"refs/tags/a b"}}, "", ArchiveParams{}, "", "", "", "ref contains whitespace"},
		{"control character in a ref", url.Values{"ref": {"refs/tags/a\nb"}}, "", ArchiveParams{}, "", "", "", "ref contains whitespace"},
		{"ref that git would read as an option", url.Values{"ref": {"--output=/tmp/evil"}}, "", ArchiveParams{}, "", "", "", "ref starts with a dash"},
		{"prefix escaping the root", url.Values{"prefix": {"../../evil"}}, "", ArchiveParams{}, "", "", "", "prefix escapes the archive root"},
		{"prefix escaping after cleaning", url.Values{"prefix": {"squid/../../evil"}}, "", ArchiveParams{}, "", "", "", "prefix escapes the archive root"},
		{"bare dot prefix", url.Values{"prefix": {"."}}, "", ArchiveParams{}, "", "", "", "prefix escapes the archive root"},
		{"control character in a prefix", url.Values{"prefix": {"squid\nmain"}}, "", ArchiveParams{}, "", "", "", "prefix contains a control character"},
		{"windows separator in a prefix", url.Values{"prefix": {`..\..\evil`}}, "", ArchiveParams{}, "", "", "", "prefix contains a backslash"},
		{"prefix over the length limit", url.Values{"prefix": {strings.Repeat("a", MaxArchivePrefixLen+1)}}, "", ArchiveParams{}, "", "", "", "over the 255 byte limit"},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, err := ParseArchiveParams(tc.query)
			rejected := err != nil
			if got != tc.want || rejected != (tc.wantErr != "") || (rejected && !strings.Contains(err.Error(), tc.wantErr)) {
				t.Fatalf("params = %+v with err %v, want %+v and an error mentioning %q", got, err, tc.want, tc.wantErr)
			}
			if rejected {
				return
			}

			repo := RepoName("squid")
			if tc.repo != "" {
				repo = tc.repo
			}
			if stem := got.Prefix.OrDefault(repo, got.Rev); tc.wantStem != "" && stem != tc.wantStem {
				t.Errorf("default prefix = %q, want %q", stem, tc.wantStem)
			}
			if tc.wantFilename != "" {
				header := http.Header{}
				got.SetHeaders(header, repo)
				mediatype, fields, err := mime.ParseMediaType(header.Get("Content-Disposition"))
				if err != nil || mediatype != "attachment" || fields["filename"] != tc.wantFilename {
					t.Errorf("Content-Disposition = %q (err %v), want an attachment with filename %q", header.Get("Content-Disposition"), err, tc.wantFilename)
				}
				if ct, sniff := header.Get("Content-Type"), header.Get("X-Content-Type-Options"); ct != tc.wantType || sniff != "nosniff" {
					t.Errorf("Content-Type = %q with X-Content-Type-Options %q, want %q and nosniff", ct, sniff, tc.wantType)
				}
			}

			query := got.Query("did:plc:limpet")
			if back, err := ParseArchiveParams(query); err != nil || back != got {
				t.Errorf("query round trip = %+v (err %v), want %+v", back, err, got)
			}
			back, err := ParseImmutableLink(ImmutableLink(testArchiveEndpoint + "?" + query.Encode()))
			if got.Rev != "" && (err != nil || back != got.Rev) {
				t.Errorf("Link round trip = %q (err %v), want %q", back, err, got.Rev)
			}
		})
	}
}

func TestArchiveFallbacks(t *testing.T) {
	hash := plumbing.NewHash("6f1d3a2b4c5d6e7f8091a2b3c4d5e6f708192a3b")
	if kept, filled := Rev("refs/heads/main").OrHash(hash), Rev("").OrHash(hash); kept != "refs/heads/main" || filled != RevFromHash(hash) {
		t.Errorf("OrHash kept %q and filled %q, want refs/heads/main and the hash %q", kept, filled, hash)
	}
	if got := Rev("").Or(RevHead); got != RevHead {
		t.Errorf("empty rev = %q, want HEAD", got)
	}
	if _, err := ParseImmutableLink(""); err == nil {
		t.Error("ParseImmutableLink must reject an empty header")
	}

	params := ArchiveParams{Rev: "main", Format: ArchiveZip, Prefix: "kelp"}
	if got := params.WithRev("6f1d3a2"); got != (ArchiveParams{Rev: "6f1d3a2", Format: ArchiveZip, Prefix: "kelp"}) || params.Rev != "main" {
		t.Errorf("WithRev = %+v leaving the receiver at %q, want only the rev replaced", got, params.Rev)
	}

	long := ArchivePrefix("").OrDefault("squid", Rev("refs/heads/"+strings.Repeat("ü", 400)))
	if _, err := ParseArchivePrefix(long.String()); len(long) > MaxArchivePrefixLen || !utf8.ValidString(long.String()) || err != nil {
		t.Errorf("default prefix is %d bytes %q (err %v), want at most %d bytes ending on a rune boundary", len(long), long, err, MaxArchivePrefixLen)
	}
}

func TestWriteArchive(t *testing.T) {
	repoPath := t.TempDir()
	require.NoError(t, os.WriteFile(filepath.Join(repoPath, "README.md"), []byte("# squid\n"), 0644))
	for _, args := range [][]string{
		{"init", "-q", "-b", "main"},
		{"add", "README.md"},
		{"-c", "user.name=nel", "-c", "user.email=nel@nel.pet", "commit", "-qm", "Initial commit"},
	} {
		cmd := exec.Command("git", args...)
		cmd.Dir = repoPath
		require.NoError(t, cmd.Run(), "git %v", args)
	}

	canceled, cancel := context.WithCancel(context.Background())
	cancel()

	cases := []struct {
		name   string
		ctx    context.Context
		prefix ArchivePrefix
		want   []string
	}{
		{
			"prefix on every entry",
			context.Background(),
			ArchivePrefix("").OrDefault("squid", "refs/heads/feat/uni"),
			[]string{"squid-feat-uni/", "squid-feat-uni/README.md"},
		},
		{"empty prefix", context.Background(), "", []string{"README.md"}},
		{"canceled context", canceled, "squid-main", nil},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			var body bytes.Buffer
			err := WriteArchive(tc.ctx, &body, repoPath, RevHead, ArchiveZip, tc.prefix)
			if tc.want == nil {
				assert.Error(t, err)
				return
			}
			require.NoError(t, err)

			entries, err := zip.NewReader(bytes.NewReader(body.Bytes()), int64(body.Len()))
			require.NoError(t, err)
			assert.Equal(t, tc.want, lo.Map(entries.File, func(f *zip.File, _ int) string { return f.Name }))
		})
	}
}
