package main

import (
	"bytes"
	"compress/gzip"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"vitrine/display"
)

// fixtureServer builds a server over the standard hermetic fixture repo,
// with a pinned theme seed so output stays deterministic.
func fixtureServer(t *testing.T) *server {
	t.Helper()
	return newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, display.NewTheme("test"))
}

// do runs one request through the handler and returns the recorded response.
// A non-nil body makes it a POST (with the given content type); otherwise a GET.
func do(s *server, target string, body []byte, hdr map[string]string) *httptest.ResponseRecorder {
	method := http.MethodGet
	var rd io.Reader
	if body != nil {
		method = http.MethodPost
		rd = bytes.NewReader(body)
	}
	req := httptest.NewRequest(method, "http://socle.example"+target, rd)
	for k, v := range hdr {
		req.Header.Set(k, v)
	}
	rec := httptest.NewRecorder()
	s.ServeHTTP(rec, req)
	return rec
}

// fetchBody is a minimal protocol-v0 fetch request: want the tip, no haves, done.
func fetchBody() []byte {
	var req bytes.Buffer
	pktline(&req, "want "+fixtureShaMain+"\n")
	req.WriteString("0000")
	pktline(&req, "done\n")
	return req.Bytes()
}

func TestDispatchStatuses(t *testing.T) {
	t.Parallel()
	s := fixtureServer(t)
	for _, c := range []struct {
		target   string
		body     []byte // non-nil means POST
		want     int
		location string // expected Location header, when a redirect
	}{
		{target: "/", want: 200},
		{target: "/-/style.css", want: 200},
		{target: "/fixturama", want: 301, location: "/fixturama/"},
		{target: "/fixturama/", want: 200},
		{target: "/fixturama.git/", want: 200},
		{target: "/nosuch/", want: 404},
		{target: "/fixturama/tree/main", want: 200},
		{target: "/fixturama/tree/main/", want: 200},
		{target: "/fixturama/tree/main/docs/", want: 200},
		{target: "/fixturama/tree/main/docs/with%20space.txt", want: 200},
		{target: "/fixturama/tree/bogus/", want: 404},
		{target: "/fixturama/tree/main..dev/", want: 400},
		{target: "/fixturama/tree/main/absent", want: 404},
		{target: "/fixturama/raw/main/README.md", want: 200},
		{target: "/fixturama/raw/main/docs", want: 404}, // a directory has no raw bytes
		{target: "/fixturama/log/main", want: 200},
		{target: "/fixturama/log/bogus", want: 404},
		{target: "/fixturama/info/refs", want: 403},
		{target: "/fixturama/info/refs?service=git-receive-pack", want: 403},
		{target: "/fixturama/info/refs?service=git-upload-pack", want: 200},
		{target: "/fixturama/git-upload-pack", body: fetchBody(), want: 200},
		{target: "/fixturama/git-upload-pack", want: 405},            // fetch RPC is POST-only
		{target: "/fixturama/tree/main/", body: []byte{}, want: 405}, // and nothing else accepts POST
		{target: "/fixturama/README.md", want: 302, location: "/fixturama/tree/main/README.md"},
		{target: "/fixturama/docs/pic.png", want: 302, location: "/fixturama/raw/main/docs/pic.png"},
	} {
		rec := do(s, c.target, c.body, nil)
		if rec.Code != c.want {
			t.Errorf("%s: status = %d, want %d (body: %.120s)", c.target, rec.Code, c.want, rec.Body.String())
		}
		if c.location != "" && rec.Header().Get("Location") != c.location {
			t.Errorf("%s: Location = %q, want %q", c.target, rec.Header().Get("Location"), c.location)
		}
	}
}

func TestCacheHeaders(t *testing.T) {
	t.Parallel()
	s := fixtureServer(t)
	const immutable = "public, max-age=31536000, immutable"
	for _, c := range []struct {
		target string
		want   string
	}{
		{"/fixturama/tree/" + fixtureShaMain + "/README.md", immutable},
		{"/fixturama/raw/" + fixtureShaMain + "/README.md", immutable},
		{"/fixturama/log/" + fixtureShaMain, immutable},
		{"/fixturama/tree/main/README.md", ""}, // a branch moves; never immutable
		{"/fixturama/raw/v1.0/README.md", ""},  // a tag names a commit but is not one
		{"/fixturama/info/refs?service=git-upload-pack", "no-cache"},
	} {
		rec := do(s, c.target, nil, nil)
		if rec.Code != 200 {
			t.Errorf("%s: status = %d, want 200", c.target, rec.Code)
		}
		if got := rec.Header().Get("Cache-Control"); got != c.want {
			t.Errorf("%s: Cache-Control = %q, want %q", c.target, got, c.want)
		}
	}
}

func TestRawResponse(t *testing.T) {
	t.Parallel()
	s := fixtureServer(t)
	rec := do(s, "/fixturama/raw/main/README.md", nil, nil)
	if rec.Body.String() != fixtureReadme {
		t.Errorf("raw body = %q, want the README", rec.Body.String())
	}
	if got := rec.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
		t.Errorf("Content-Type = %q, want text/plain (markdown must display, not download)", got)
	}
	if got, want := rec.Header().Get("Content-Length"), fmt.Sprint(len(fixtureReadme)); got != want {
		t.Errorf("Content-Length = %q, want %s", got, want)
	}
}

func TestInfoRefsProtocolVariants(t *testing.T) {
	t.Parallel()
	s := fixtureServer(t)
	t.Run("v0 advertisement carries the service preamble and refs", func(t *testing.T) {
		rec := do(s, "/fixturama/info/refs?service=git-upload-pack", nil, nil)
		body := rec.Body.String()
		if !strings.HasPrefix(body, "001e# service=git-upload-pack\n0000") {
			t.Errorf("v0 advertisement should start with the service pkt-line, got %.60q", body)
		}
		if !strings.Contains(body, "refs/heads/main") || !strings.Contains(body, fixtureShaMain) {
			t.Errorf("advertisement lacks main's ref line:\n%s", body)
		}
		if got := rec.Header().Get("Content-Type"); got != "application/x-git-upload-pack-advertisement" {
			t.Errorf("Content-Type = %q", got)
		}
	})
	t.Run("v2 advertisement has no preamble", func(t *testing.T) {
		rec := do(s, "/fixturama/info/refs?service=git-upload-pack", nil, map[string]string{"Git-Protocol": "version=2"})
		body := rec.Body.String()
		if strings.Contains(body, "# service=") {
			t.Errorf("v2 advertisement must not carry the v0 preamble, got %.60q", body)
		}
		if !strings.Contains(body, "version 2") {
			t.Errorf("expected a v2 capability advertisement:\n%s", body)
		}
	})
}

func TestUploadPackRPC(t *testing.T) {
	t.Parallel()
	s := fixtureServer(t)
	check := func(t *testing.T, rec *httptest.ResponseRecorder) {
		t.Helper()
		if rec.Code != 200 {
			t.Fatalf("status = %d", rec.Code)
		}
		if !bytes.Contains(rec.Body.Bytes(), []byte("NAK")) || !bytes.Contains(rec.Body.Bytes(), []byte("PACK")) {
			t.Errorf("expected NAK and a packfile in the response, got %d bytes", rec.Body.Len())
		}
	}
	t.Run("plain body", func(t *testing.T) {
		check(t, do(s, "/fixturama/git-upload-pack", fetchBody(), nil))
	})
	t.Run("gzip body", func(t *testing.T) {
		var zbuf bytes.Buffer
		zw := gzip.NewWriter(&zbuf)
		zw.Write(fetchBody())
		zw.Close()
		check(t, do(s, "/fixturama/git-upload-pack", zbuf.Bytes(), map[string]string{"Content-Encoding": "gzip"}))
	})
	t.Run("malformed gzip is a clean 400", func(t *testing.T) {
		rec := do(s, "/fixturama/git-upload-pack", []byte("not gzip"), map[string]string{"Content-Encoding": "gzip"})
		if rec.Code != 400 {
			t.Errorf("status = %d, want 400", rec.Code)
		}
	})
}

// TestFileViewModes covers the file-view decision ladder with blobs the standard
// fixture doesn't carry: an image, binary data, and an oversized text file.
// These shas are deliberately not pinned; nothing here snapshots output.
func TestFileViewModes(t *testing.T) {
	t.Parallel()
	dir := t.TempDir()
	gitFixture(t, dir, "init", "-q", "-b", "main", "--object-format=sha1", ".")
	writeFixtureFile(t, dir, "logo.png", "\x89PNG\r\n\x1a\n not actually a decodable image, which is fine: only the extension matters")
	writeFixtureFile(t, dir, "data.bin", "binary\x00data")
	writeFixtureFile(t, dir, "huge.txt", strings.Repeat("a", display.MaxRenderSize+1))
	gitFixture(t, dir, "add", "-A")
	gitFixture(t, dir, "commit", "-q", "-m", "media sampler")
	rp, err := openRepo("media", repoConfig{Path: dir})
	if err != nil {
		t.Fatal(err)
	}
	s := newServer(map[string]*repo{"media": rp}, display.NewTheme("test"))
	for _, c := range []struct {
		target string
		want   string
	}{
		{"/media/tree/main/logo.png", `<img class="file" src="/media/raw/main/logo.png"`},
		{"/media/tree/main/data.bin", `binary data; <a href="/media/raw/main/data.bin">view raw</a>`},
		{"/media/tree/main/huge.txt", `too large to display inline; <a href="/media/raw/main/huge.txt">view raw</a>`},
	} {
		rec := do(s, c.target, nil, nil)
		if rec.Code != 200 {
			t.Errorf("%s: status = %d", c.target, rec.Code)
		}
		if !strings.Contains(rec.Body.String(), c.want) {
			t.Errorf("%s: body lacks %q:\n%.400s", c.target, c.want, rec.Body.String())
		}
	}
}
