package service

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/glebarez/sqlite"
	"github.com/pingcap/agent-git-service/internal/db"
	"github.com/pingcap/agent-git-service/internal/gitstore"
	"gorm.io/gorm"
)

func serveRepositoryReceivePackForTest(svc *Service, writer http.ResponseWriter, request *http.Request, repositoryID uint) (RepositoryReceivePackResult, error) {
	result, response, err := svc.ServeRepositoryReceivePack(request, repositoryID)
	if response != nil {
		defer response.Close()
	}
	if err != nil {
		return result, err
	}
	if response == nil {
		return result, ErrRepositoryMigrationConflict
	}
	err = response.Replay(writer)
	result.ResponseCommitted = true
	return result, err
}

func TestServeRepositoryReceivePackProjectsDefaultBranchAndSanitizesEnvironment(t *testing.T) {
	database, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "receive-pack.db")), &gorm.Config{})
	if err != nil {
		t.Fatalf("open database: %v", err)
	}
	if err := database.AutoMigrate(&db.Repository{}, &db.RepositoryMigrationFreeze{}, &db.RepositoryPushRecovery{}, &db.RepositoryPushOutbox{}, &db.RepositoryWebhookFanout{}, &db.Release{}); err != nil {
		t.Fatalf("AutoMigrate: %v", err)
	}
	root := filepath.Join(t.TempDir(), "repositories")
	store, err := gitstore.New(root)
	if err != nil {
		t.Fatalf("gitstore.New: %v", err)
	}
	svc, err := New(Config{DB: database, Git: store})
	if err != nil {
		t.Fatalf("service.New: %v", err)
	}
	repository := db.Repository{ID: 41, Name: "repo", FullName: "owner/repo", DefaultBranch: "main"}
	if err := database.Create(&repository).Error; err != nil {
		t.Fatalf("create repository: %v", err)
	}
	repositoryPath, err := store.GetRepoPath(t.Context(), repository.FullName)
	if err != nil {
		t.Fatalf("GetRepoPath: %v", err)
	}
	runReceivePackFixtureGit(t, root, "init", "--bare", "--initial-branch=main", repositoryPath)
	commit := createReceivePackFixtureCommit(t, repositoryPath)
	runReceivePackFixtureGit(t, root, "--git-dir", repositoryPath, "update-ref", "refs/heads/feature", commit)

	directory := t.TempDir()
	started := filepath.Join(directory, "started")
	release := filepath.Join(directory, "release")
	environment := filepath.Join(directory, "environment")
	script := filepath.Join(directory, "receive-pack-cgi.sh")
	source := fmt.Sprintf("#!/bin/sh\nenv >%s\n: >%s\nwhile [ ! -f %s ]; do sleep 0.01; done\nprintf 'Content-Type: application/x-git-receive-pack-result\\r\\n\\r\\nok'\n",
		strconv.Quote(environment), strconv.Quote(started), strconv.Quote(release))
	if err := os.WriteFile(script, []byte(source), 0o700); err != nil {
		t.Fatalf("write CGI script: %v", err)
	}
	svc.testRepositoryReceivePackBackend = script

	request := httptest.NewRequest("POST", "http://evil.invalid/other/repo.git/git-receive-pack?service=evil", nil)
	request.Header.Set("X-Injected-Environment", "should-not-reach-child")
	request.Header.Set("Git-Protocol", "version=2")
	response := httptest.NewRecorder()
	receiveDone := make(chan error, 1)
	go func() {
		_, err := serveRepositoryReceivePackForTest(svc, response, request, repository.ID)
		receiveDone <- err
	}()
	waitForReceivePackFixtureFile(t, started)
	if err := os.WriteFile(release, nil, 0o600); err != nil {
		t.Fatalf("release CGI: %v", err)
	}
	select {
	case err := <-receiveDone:
		if err != nil {
			t.Fatalf("ServeRepositoryReceivePack: %v", err)
		}
	case <-time.After(5 * time.Second):
		t.Fatal("receive-pack did not complete")
	}
	var projected db.Repository
	if err := database.First(&projected, repository.ID).Error; err != nil {
		t.Fatalf("load repository: %v", err)
	}
	if projected.DefaultBranch != "feature" {
		t.Fatalf("default branch = %q, want feature", projected.DefaultBranch)
	}
	environmentBytes, err := os.ReadFile(environment)
	if err != nil {
		t.Fatalf("read CGI environment: %v", err)
	}
	childEnvironment := string(environmentBytes)
	for _, want := range []string{
		"GIT_PROJECT_ROOT=" + root,
		"PATH_INFO=/owner/repo.git/git-receive-pack",
		"REQUEST_URI=/owner/repo.git/git-receive-pack",
		"QUERY_STRING=",
		"CONTENT_TYPE=application/x-git-receive-pack-request",
	} {
		if !strings.Contains(childEnvironment, want+"\n") {
			t.Fatalf("CGI environment missing %q:\n%s", want, childEnvironment)
		}
	}
	for _, forbidden := range []string{"evil.invalid", "other/repo", "service=evil", "should-not-reach-child", "version=2"} {
		if strings.Contains(childEnvironment, forbidden) {
			t.Fatalf("CGI environment contains caller-controlled %q:\n%s", forbidden, childEnvironment)
		}
	}
}

func TestPrepareRepositoryReceivePackRequestReturnsBoundedTouchedRefsAndReplaysBody(t *testing.T) {
	command := strings.Repeat("1", 40) + " " + strings.Repeat("2", 40) + " refs/heads/feature\x00report-status\n"
	payload := fmt.Sprintf("%04x%s0000PACK", len(command)+4, command)
	request := httptest.NewRequest(http.MethodPost, "http://example.invalid/owner/repo.git/git-receive-pack", strings.NewReader(payload))
	refs, err := prepareRepositoryReceivePackRequest(request)
	if err != nil {
		t.Fatalf("prepareRepositoryReceivePackRequest: %v", err)
	}
	if len(refs) != 1 || refs[0].Ref != "refs/heads/feature" || refs[0].Before != strings.Repeat("1", 40) || refs[0].After != strings.Repeat("2", 40) {
		t.Fatalf("touched refs = %v", refs)
	}
	replayed, err := io.ReadAll(request.Body)
	if err != nil {
		t.Fatalf("read replayed body: %v", err)
	}
	if string(replayed) != payload {
		t.Fatalf("replayed body = %q, want %q", replayed, payload)
	}
}

func TestServeRepositoryReceivePackProjectionFailureDoesNotCommitCGIResponse(t *testing.T) {
	fixture := newReceivePackFailureFixture(t, 43)
	joined := filepath.Join(t.TempDir(), "joined")
	fixture.writeBackend(t, fmt.Sprintf("#!/bin/sh\nprintf 'Content-Type: application/x-git-receive-pack-result\\r\\n\\r\\nok'\n: >%s\n", strconv.Quote(joined)))
	hookCalled := false
	fixture.service.testRepositoryReceivePackAfterChild = func() {
		hookCalled = true
		spools, err := filepath.Glob(filepath.Join(fixture.root, ".ags-receive-pack-*.response"))
		if err != nil || len(spools) != 1 {
			t.Fatalf("live response spools = %v, err = %v, want one", spools, err)
		}
		info, err := os.Stat(spools[0])
		if err != nil {
			t.Fatalf("stat live response spool: %v", err)
		}
		if info.Mode().Perm() != 0o600 {
			t.Fatalf("response spool mode = %o, want 600", info.Mode().Perm())
		}
		result := fixture.database.Model(&db.Repository{}).
			Where("id = ?", fixture.repository.ID).
			Update("full_name", "owner/renamed")
		if result.Error != nil || result.RowsAffected != 1 {
			t.Fatalf("invalidate session identity: rows=%d err=%v", result.RowsAffected, result.Error)
		}
	}
	writer := newTrackingResponseWriter(nil)
	request := fixture.request()
	result, err := serveRepositoryReceivePackForTest(fixture.service, writer, request, fixture.repository.ID)
	if !errors.Is(err, ErrRepositoryMigrationConflict) {
		t.Fatalf("ServeRepositoryReceivePack error = %v, want identity projection failure", err)
	}
	if !hookCalled {
		t.Fatal("post-child projection hook was not called")
	}
	if result.ResponseCommitted || result.Converged || !result.ChildStarted || !result.ChildJoined {
		t.Fatalf("projection failure result = %+v", result)
	}
	if _, err := os.Stat(joined); err != nil {
		t.Fatalf("child was not joined before projection: %v", err)
	}
	writer.assertUncommitted(t)
	fixture.assertDefaultBranch(t, "main")
	if _, ok := repoCacheGet(request.Context(), fixture.repository.FullName); !ok {
		t.Fatal("projection failure invalidated repository cache")
	}
	fixture.assertNoResponseSpools(t)
}

func TestServeRepositoryReceivePackPrecommitFailuresStillConverge(t *testing.T) {
	tests := []struct {
		name       string
		body       string
		maxBody    int64
		spoolWrite bool
		continuous bool
	}{
		{name: "oversized header", body: "X-Large: " + strings.Repeat("a", repositoryReceivePackMaxHeaderBytes+1) + "\r\n\r\nbody"},
		{name: "oversized body", body: "Content-Type: application/x-git-receive-pack-result\r\n\r\n0123456789abcdef", maxBody: 8},
		{name: "oversized body cancels continuous child", maxBody: 8, continuous: true},
		{name: "child nonzero", body: "Content-Type: application/x-git-receive-pack-result\r\n\r\nbody\nexit 7"},
		{name: "spool write failure", body: "Content-Type: application/x-git-receive-pack-result\r\n\r\nbody", spoolWrite: true},
	}
	for index, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			fixture := newReceivePackFailureFixture(t, uint(50+index))
			joined := filepath.Join(t.TempDir(), "joined")
			source := "#!/bin/sh\nprintf '" + test.body + "'\n: >" + strconv.Quote(joined) + "\n"
			if test.continuous {
				source = "#!/bin/sh\nprintf 'Content-Type: application/x-git-receive-pack-result\\r\\n\\r\\n'\nwhile :; do printf '0123456789abcdef'; done\n"
			}
			if test.name == "child nonzero" {
				source = "#!/bin/sh\nprintf 'Content-Type: application/x-git-receive-pack-result\\r\\n\\r\\nbody'\n: >" + strconv.Quote(joined) + "\nexit 7\n"
			}
			fixture.writeBackend(t, source)
			fixture.service.testRepositoryReceivePackMaxBodyBytes = test.maxBody
			var failedSpool string
			if test.spoolWrite {
				failedSpool = filepath.Join(fixture.root, ".ags-receive-pack-write-failure.response")
				if err := os.WriteFile(failedSpool, nil, 0o600); err != nil {
					t.Fatalf("create read-only spool fixture: %v", err)
				}
				fixture.service.testRepositoryReceivePackSpool = func() (*os.File, error) {
					return os.Open(failedSpool)
				}
			}
			writer := newTrackingResponseWriter(nil)
			request := fixture.request()
			started := time.Now()
			result, err := serveRepositoryReceivePackForTest(fixture.service, writer, request, fixture.repository.ID)
			if err == nil {
				t.Fatal("ServeRepositoryReceivePack unexpectedly succeeded")
			}
			if !result.ShouldSchedulePostPush() || result.ResponseCommitted {
				t.Fatalf("pre-commit convergence result = %+v", result)
			}
			if test.continuous && time.Since(started) > 2*time.Second {
				t.Fatalf("continuous child precommit cancellation took %s", time.Since(started))
			}
			// Cancel-first precommit failures may terminate the backend before it
			// reaches its sentinel. A normal nonzero exit still proves the child
			// ran to completion before the session was released.
			if test.name == "child nonzero" {
				if _, err := os.Stat(joined); err != nil {
					t.Fatalf("child was not fully drained and joined: %v", err)
				}
			}
			writer.assertUncommitted(t)
			fixture.assertDefaultBranch(t, "feature")
			if _, ok := repoCacheGet(request.Context(), fixture.repository.FullName); ok {
				t.Fatal("successful convergence did not invalidate repository cache")
			}
			fixture.assertNoResponseSpools(t)
			if failedSpool != "" {
				if _, err := os.Stat(failedSpool); !os.IsNotExist(err) {
					t.Fatalf("failed response spool was not removed: %v", err)
				}
			}
		})
	}
}

func TestServeRepositoryReceivePackReplayFailureIsCommitted(t *testing.T) {
	fixture := newReceivePackFailureFixture(t, 60)
	fixture.writeBackend(t, "#!/bin/sh\nprintf 'Content-Type: application/x-git-receive-pack-result\\r\\n\\r\\nok'\n")
	replayErr := errors.New("client disconnected")
	writer := newTrackingResponseWriter(replayErr)
	request := fixture.request()
	result, err := serveRepositoryReceivePackForTest(fixture.service, writer, request, fixture.repository.ID)
	if !errors.Is(err, ErrRepositoryReceivePackResponseReplay) {
		t.Fatalf("ServeRepositoryReceivePack error = %v, want committed replay error", err)
	}
	if writer.writeHeaders != 1 || writer.writes != 1 || writer.status != http.StatusOK {
		t.Fatalf("replay calls = headers:%d writes:%d status:%d, want one committed response", writer.writeHeaders, writer.writes, writer.status)
	}
	if !result.ShouldSchedulePostPush() || !result.ResponseCommitted {
		t.Fatalf("replay failure result = %+v", result)
	}
	fixture.assertDefaultBranch(t, "feature")
	if _, ok := repoCacheGet(request.Context(), fixture.repository.FullName); ok {
		t.Fatal("successful convergence did not invalidate repository cache")
	}
	fixture.assertNoResponseSpools(t)
}

func TestServeRepositoryReceivePackReleasesWriterBeforeResponseReplay(t *testing.T) {
	fixture := newReceivePackFailureFixture(t, 63)
	fixture.writeBackend(t, "#!/bin/sh\nprintf 'Content-Type: application/x-git-receive-pack-result\\r\\n\\r\\nok'\n")
	result, response, err := fixture.service.ServeRepositoryReceivePack(fixture.request(), fixture.repository.ID)
	if err != nil {
		t.Fatalf("ServeRepositoryReceivePack: %v", err)
	}
	if response == nil {
		t.Fatal("ServeRepositoryReceivePack returned no frozen response")
	}
	defer response.Close()
	if !result.ShouldSchedulePostPush() || result.ResponseCommitted {
		t.Fatalf("transport-independent result = %+v", result)
	}
	writer := newBlockingReplayWriter()
	replayDone := make(chan error, 1)
	go func() { replayDone <- response.Replay(writer) }()
	select {
	case <-writer.started:
	case <-time.After(5 * time.Second):
		t.Fatal("response replay did not reach blocking writer")
	}
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	session, err := fixture.service.repositoryMutations.AcquireReceivePackSession(ctx, fixture.repository.ID)
	if err != nil {
		t.Fatalf("writer remained held during response replay: %v", err)
	}
	if err := session.Release(); err != nil {
		t.Fatalf("release probe writer: %v", err)
	}
	close(writer.release)
	select {
	case err := <-replayDone:
		if err != nil {
			t.Fatalf("response replay: %v", err)
		}
	case <-time.After(5 * time.Second):
		t.Fatal("response replay did not finish")
	}
	response.Close()
	fixture.assertNoResponseSpools(t)
}

func TestServeRepositoryReceivePackSerializesWriterAfterBeforeRefSnapshot(t *testing.T) {
	fixture := newReceivePackFailureFixture(t, 60)
	fixture.writeBackend(t, "#!/bin/sh\nprintf 'Content-Type: application/x-git-receive-pack-result\\r\\n\\r\\nok'\n")
	snapshotted := make(chan struct{})
	continueReceive := make(chan struct{})
	fixture.service.testRepositoryReceivePackAfterBeforeRefs = func() {
		close(snapshotted)
		<-continueReceive
	}
	receiveDone := make(chan error, 1)
	go func() {
		_, err := serveRepositoryReceivePackForTest(fixture.service, newTrackingResponseWriter(nil), fixture.request(), fixture.repository.ID)
		receiveDone <- err
	}()
	select {
	case <-snapshotted:
	case <-time.After(5 * time.Second):
		t.Fatal("receive-pack did not reach before-ref snapshot")
	}
	writerAcquired := make(chan error, 1)
	go func() {
		session, err := fixture.service.repositoryMutations.AcquireReceivePackSession(context.Background(), fixture.repository.ID)
		if err == nil {
			err = session.Release()
		}
		writerAcquired <- err
	}()
	select {
	case err := <-writerAcquired:
		t.Fatalf("concurrent writer crossed receive-pack snapshot window: %v", err)
	case <-time.After(100 * time.Millisecond):
	}
	close(continueReceive)
	if err := <-receiveDone; err != nil {
		t.Fatalf("ServeRepositoryReceivePack: %v", err)
	}
	select {
	case err := <-writerAcquired:
		if err != nil {
			t.Fatalf("writer after receive-pack release: %v", err)
		}
	case <-time.After(5 * time.Second):
		t.Fatal("writer remained blocked after receive-pack release")
	}
}

func TestServeRepositoryReceivePackCancellationStillConverges(t *testing.T) {
	fixture := newReceivePackFailureFixture(t, 61)
	started := filepath.Join(t.TempDir(), "started")
	fixture.writeBackend(t, fmt.Sprintf("#!/bin/sh\nprintf 'Content-Type: application/x-git-receive-pack-result\\r\\n\\r\\npartial'\n: >%s\nwhile :; do sleep 1; done\n", strconv.Quote(started)))
	joined := false
	fixture.service.testRepositoryReceivePackAfterChild = func() { joined = true }
	request := fixture.request()
	requestCtx, cancel := context.WithCancel(request.Context())
	request = request.WithContext(requestCtx)
	writer := newTrackingResponseWriter(nil)
	type receiveResult struct {
		result RepositoryReceivePackResult
		err    error
	}
	done := make(chan receiveResult, 1)
	go func() {
		result, err := serveRepositoryReceivePackForTest(fixture.service, writer, request, fixture.repository.ID)
		done <- receiveResult{result: result, err: err}
	}()
	waitForReceivePackFixtureFile(t, started)
	cancel()
	var received receiveResult
	select {
	case received = <-done:
	case <-time.After(5 * time.Second):
		t.Fatal("cancelled receive-pack did not join child")
	}
	if !errors.Is(received.err, context.Canceled) {
		t.Fatalf("ServeRepositoryReceivePack error = %v, want context.Canceled", received.err)
	}
	if !received.result.ShouldSchedulePostPush() || received.result.ResponseCommitted {
		t.Fatalf("cancel convergence result = %+v", received.result)
	}
	if !joined {
		t.Fatal("convergence started before child join completed")
	}
	writer.assertUncommitted(t)
	fixture.assertDefaultBranch(t, "feature")
	if _, ok := repoCacheGet(request.Context(), fixture.repository.FullName); ok {
		t.Fatal("successful cancellation convergence did not invalidate repository cache")
	}
	fixture.assertNoResponseSpools(t)
}

func TestServeRepositoryReceivePackServerShutdownStillConverges(t *testing.T) {
	fixture := newReceivePackFailureFixture(t, 62)
	fixture.writeBackend(t, "#!/bin/sh\nprintf 'Content-Type: application/x-git-receive-pack-result\\r\\n\\r\\nok'\n")
	serverCtx, cancelServer := context.WithCancel(context.Background())
	fixture.service.Ctx = serverCtx
	fixture.service.testRepositoryReceivePackAfterChild = cancelServer
	request := fixture.request()
	writer := newTrackingResponseWriter(nil)
	result, err := serveRepositoryReceivePackForTest(fixture.service, writer, request, fixture.repository.ID)
	if err != nil {
		t.Fatalf("ServeRepositoryReceivePack after server cancellation: %v", err)
	}
	if serverCtx.Err() == nil {
		t.Fatal("server context was not cancelled after child join")
	}
	if !result.ShouldSchedulePostPush() || !result.ResponseCommitted {
		t.Fatalf("server cancellation convergence result = %+v", result)
	}
	fixture.assertDefaultBranch(t, "feature")
	if _, ok := repoCacheGet(request.Context(), fixture.repository.FullName); ok {
		t.Fatal("successful shutdown convergence did not invalidate repository cache")
	}
	fixture.assertNoResponseSpools(t)
}

func TestServeRepositoryReceivePackUnknownDeltaKeepsDurableMarkerUntilRecovery(t *testing.T) {
	fixture := newReceivePackFailureFixture(t, 63)
	fixture.writeBackend(t, "#!/bin/sh\nprintf 'Content-Type: application/x-git-receive-pack-result\\r\\n\\r\\nok'\n")
	repositoryPath := filepath.Join(fixture.root, "owner", "repo.git")
	movedPath := repositoryPath + ".snapshot-failure"
	fixture.service.testRepositoryReceivePackAfterChild = func() {
		if err := os.Rename(repositoryPath, movedPath); err != nil {
			panic(err)
		}
	}
	writer := newTrackingResponseWriter(nil)
	result, err := serveRepositoryReceivePackForTest(fixture.service, writer, fixture.request(), fixture.repository.ID)
	if err == nil || !result.NeedsPostPushRecovery() || result.RefDeltaKnown {
		t.Fatalf("unknown delta result = %+v, err=%v", result, err)
	}
	writer.assertUncommitted(t)
	var marker db.RepositoryPushRecovery
	if err := fixture.database.First(&marker, "repository_id = ?", fixture.repository.ID).Error; err != nil {
		t.Fatalf("durable marker missing: %v", err)
	}
	if marker.OperationID == "" || marker.CommandsJSON == "" {
		t.Fatalf("durable marker incomplete: %+v", marker)
	}
	if err := os.Rename(movedPath, repositoryPath); err != nil {
		t.Fatalf("restore repository: %v", err)
	}
	recovered, attempted, err := fixture.service.RecoverRepositoryPushMarkers(t.Context(), 1)
	if err != nil || recovered != 1 || attempted != 1 {
		t.Fatalf("RecoverRepositoryPushMarkers = recovered:%d attempted:%d, %v", recovered, attempted, err)
	}
	var markerCount, outboxCount int64
	fixture.database.Model(&db.RepositoryPushRecovery{}).Count(&markerCount)
	fixture.database.Model(&db.RepositoryPushOutbox{}).Count(&outboxCount)
	if markerCount != 0 || outboxCount != 0 {
		t.Fatalf("known-empty recovery rows = marker:%d outbox:%d", markerCount, outboxCount)
	}
}

type receivePackFailureFixture struct {
	service    *Service
	database   *gorm.DB
	root       string
	repository db.Repository
}

func newReceivePackFailureFixture(t *testing.T, repositoryID uint) receivePackFailureFixture {
	t.Helper()
	database, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "receive-pack.db")), &gorm.Config{})
	if err != nil {
		t.Fatalf("open database: %v", err)
	}
	if err := database.AutoMigrate(&db.Repository{}, &db.RepositoryMigrationFreeze{}, &db.RepositoryPushRecovery{}, &db.RepositoryPushOutbox{}, &db.RepositoryWebhookFanout{}, &db.Release{}); err != nil {
		t.Fatalf("AutoMigrate: %v", err)
	}
	root := filepath.Join(t.TempDir(), "repositories")
	store, err := gitstore.New(root)
	if err != nil {
		t.Fatalf("gitstore.New: %v", err)
	}
	svc, err := New(Config{DB: database, Git: store})
	if err != nil {
		t.Fatalf("service.New: %v", err)
	}
	repository := db.Repository{ID: repositoryID, Name: "repo", FullName: "owner/repo", DefaultBranch: "main"}
	if err := database.Create(&repository).Error; err != nil {
		t.Fatalf("create repository: %v", err)
	}
	repositoryPath, err := store.GetRepoPath(t.Context(), repository.FullName)
	if err != nil {
		t.Fatalf("GetRepoPath: %v", err)
	}
	runReceivePackFixtureGit(t, root, "init", "--bare", "--initial-branch=main", repositoryPath)
	commit := createReceivePackFixtureCommit(t, repositoryPath)
	runReceivePackFixtureGit(t, root, "--git-dir", repositoryPath, "update-ref", "refs/heads/feature", commit)
	return receivePackFailureFixture{service: svc, database: database, root: root, repository: repository}
}

func (fixture receivePackFailureFixture) writeBackend(t *testing.T, source string) {
	t.Helper()
	path := filepath.Join(t.TempDir(), "receive-pack-cgi.sh")
	if err := os.WriteFile(path, []byte(source), 0o700); err != nil {
		t.Fatalf("write CGI script: %v", err)
	}
	fixture.service.testRepositoryReceivePackBackend = path
}

func (fixture receivePackFailureFixture) request() *http.Request {
	request := httptest.NewRequest(http.MethodPost, "http://evil.invalid/other/repo.git/git-receive-pack?service=evil", nil)
	request = request.WithContext(ContextWithRepoCache(request.Context()))
	repoCacheSet(request.Context(), fixture.repository)
	request.Header.Set("X-Injected-Environment", "should-not-reach-child")
	return request
}

func (fixture receivePackFailureFixture) assertDefaultBranch(t *testing.T, want string) {
	t.Helper()
	var repository db.Repository
	if err := fixture.database.First(&repository, fixture.repository.ID).Error; err != nil {
		t.Fatalf("load repository: %v", err)
	}
	if repository.DefaultBranch != want {
		t.Fatalf("default branch = %q, want %q", repository.DefaultBranch, want)
	}
}

func (fixture receivePackFailureFixture) assertNoResponseSpools(t *testing.T) {
	t.Helper()
	spools, err := filepath.Glob(filepath.Join(fixture.root, ".ags-receive-pack-*.response"))
	if err != nil {
		t.Fatalf("glob response spools: %v", err)
	}
	if len(spools) != 0 {
		t.Fatalf("response spools leaked: %v", spools)
	}
}

type trackingResponseWriter struct {
	header       http.Header
	writeHeaders int
	writes       int
	status       int
	writeErr     error
}

type blockingReplayWriter struct {
	header  http.Header
	started chan struct{}
	release chan struct{}
}

func newBlockingReplayWriter() *blockingReplayWriter {
	return &blockingReplayWriter{header: make(http.Header), started: make(chan struct{}), release: make(chan struct{})}
}

func (writer *blockingReplayWriter) Header() http.Header { return writer.header }
func (*blockingReplayWriter) WriteHeader(int)            {}
func (writer *blockingReplayWriter) Write(body []byte) (int, error) {
	close(writer.started)
	<-writer.release
	return len(body), nil
}

func newTrackingResponseWriter(writeErr error) *trackingResponseWriter {
	return &trackingResponseWriter{header: make(http.Header), writeErr: writeErr}
}

func (writer *trackingResponseWriter) Header() http.Header { return writer.header }

func (writer *trackingResponseWriter) WriteHeader(statusCode int) {
	writer.writeHeaders++
	writer.status = statusCode
}

func (writer *trackingResponseWriter) Write(body []byte) (int, error) {
	writer.writes++
	if writer.writeErr != nil {
		return 0, writer.writeErr
	}
	return len(body), nil
}

func (writer *trackingResponseWriter) assertUncommitted(t *testing.T) {
	t.Helper()
	if writer.writeHeaders != 0 || writer.writes != 0 || len(writer.header) != 0 {
		t.Fatalf("response committed before projection: headers=%d writes=%d values=%v", writer.writeHeaders, writer.writes, writer.header)
	}
}

func createReceivePackFixtureCommit(t *testing.T, repositoryPath string) string {
	t.Helper()
	tree := strings.TrimSpace(runReceivePackFixtureGit(t, "", "--git-dir", repositoryPath, "mktree"))
	command := exec.Command("git", "--git-dir", repositoryPath, "commit-tree", tree)
	command.Stdin = strings.NewReader("fixture\n")
	command.Env = append(os.Environ(),
		"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.invalid", "GIT_AUTHOR_DATE=2000-01-01T00:00:00Z",
		"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.invalid", "GIT_COMMITTER_DATE=2000-01-01T00:00:00Z",
	)
	output, err := command.CombinedOutput()
	if err != nil {
		t.Fatalf("git commit-tree: %v: %s", err, output)
	}
	return strings.TrimSpace(string(output))
}

func runReceivePackFixtureGit(t *testing.T, directory string, arguments ...string) string {
	t.Helper()
	command := exec.Command("git", arguments...)
	if directory != "" {
		command.Dir = directory
	}
	output, err := command.CombinedOutput()
	if err != nil {
		t.Fatalf("git %s: %v: %s", strings.Join(arguments, " "), err, output)
	}
	return string(output)
}

func waitForReceivePackFixtureFile(t *testing.T, path string) {
	t.Helper()
	deadline := time.Now().Add(5 * time.Second)
	for time.Now().Before(deadline) {
		if _, err := os.Stat(path); err == nil {
			return
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatalf("timed out waiting for %s", path)
}
