package gitutil

import (
	"bufio"
	"context"
	"fmt"
	"io"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"
)

const testSparseDir = "/.tangled/workflows"

func newGitRepo(t *testing.T, initArgs ...string) (string, func(args ...string) string) {
	t.Helper()
	// ignore the developer's git config
	t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
	t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)
	t.Setenv("GIT_AUTHOR_NAME", "spindle")
	t.Setenv("GIT_AUTHOR_EMAIL", "spindle@tangled.org")
	t.Setenv("GIT_COMMITTER_NAME", "spindle")
	t.Setenv("GIT_COMMITTER_EMAIL", "spindle@tangled.org")

	src := t.TempDir()
	run := func(args ...string) string {
		t.Helper()
		cmd := exec.Command("git", append([]string{"-C", src}, args...)...)
		out, err := cmd.CombinedOutput()
		if err != nil {
			t.Fatalf("git %v: %v: %s", args, err, out)
		}
		return strings.TrimSpace(string(out))
	}

	run(append([]string{"init", "-b", "main"}, initArgs...)...)
	return src, run
}

func commitWorkflow(t *testing.T, src string, run func(args ...string) string) string {
	t.Helper()
	if err := os.MkdirAll(filepath.Join(src, ".tangled", "workflows"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(src, ".tangled", "workflows", "ci.yml"), []byte("when: []\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	run("add", ".")
	run("commit", "-m", "workflows")
	return run("rev-parse", "HEAD")
}

func TestSparseSync(t *testing.T) {
	src, run := newGitRepo(t)

	rev := commitWorkflow(t, src, run)

	path := filepath.Join(t.TempDir(), "repo")
	for _, stage := range []string{"clone", "fetch"} {
		if err := SparseSync(context.Background(), src, path, rev, testSparseDir); err != nil {
			t.Fatalf("%s: %v", stage, err)
		}
		if _, err := os.Stat(filepath.Join(path, ".tangled", "workflows", "ci.yml")); err != nil {
			t.Fatalf("%s: sparse file missing from the checkout: %v", stage, err)
		}
	}
}

func TestSparseSync_DanglingRemoteHead(t *testing.T) {
	src, run := newGitRepo(t)

	sha := commitWorkflow(t, src, run)

	// switch branch to master and point HEAD to unborn main
	run("branch", "-m", "main", "master")
	run("symbolic-ref", "HEAD", "refs/heads/main")

	path := filepath.Join(t.TempDir(), "repo")
	// syncing by commit sha must succeed even when remote HEAD is dangling
	if err := SparseSync(context.Background(), src, path, sha, testSparseDir); err != nil {
		t.Fatalf("sync by sha failed on dangling HEAD remote: %v", err)
	}
	if _, err := os.Stat(filepath.Join(path, ".tangled", "workflows", "ci.yml")); err != nil {
		t.Fatalf("sparse file missing: %v", err)
	}

	// syncing by ref name must also succeed
	path2 := filepath.Join(t.TempDir(), "repo2")
	if err := SparseSync(context.Background(), src, path2, "refs/heads/master", testSparseDir); err != nil {
		t.Fatalf("sync by ref failed on dangling HEAD remote: %v", err)
	}
}

func TestSparseSync_Sha256Remote(t *testing.T) {
	src, run := newGitRepo(t, "--object-format=sha256")
	sha := commitWorkflow(t, src, run)
	if len(sha) != sha256HexLen {
		t.Fatalf("fixture commit %q is not a sha256 id", sha)
	}

	for name, rev := range map[string]string{"by sha": sha, "by HEAD": ""} {
		path := filepath.Join(t.TempDir(), "repo")
		if err := SparseSync(context.Background(), src, path, rev, testSparseDir); err != nil {
			t.Fatalf("%s: %v", name, err)
		}
		if _, err := os.Stat(filepath.Join(path, ".tangled", "workflows", "ci.yml")); err != nil {
			t.Fatalf("%s: sparse file missing: %v", name, err)
		}
	}
}

func TestSparseSync_Sha256DanglingHeadByRef(t *testing.T) {
	src, run := newGitRepo(t, "--object-format=sha256")
	commitWorkflow(t, src, run)
	run("branch", "-m", "main", "master")
	run("symbolic-ref", "HEAD", "refs/heads/main")

	path := filepath.Join(t.TempDir(), "repo")
	if err := SparseSync(context.Background(), src, path, "refs/heads/master", testSparseDir); err != nil {
		t.Fatalf("sync by ref failed on a sha256 remote with a dangling HEAD: %v", err)
	}
}

func TestRefContainsCommit_Sha256Remote(t *testing.T) {
	src, run := newGitRepo(t, "--object-format=sha256")
	sha := commitWorkflow(t, src, run)

	got, err := RefContainsCommit(context.Background(), src, "refs/heads/main", sha)
	if err != nil || !got {
		t.Fatalf("RefContainsCommit() = %t, err = %v, want true on a sha256 remote", got, err)
	}
}

func TestRefContainsCommit(t *testing.T) {
	src, run := newGitRepo(t)

	if err := os.WriteFile(filepath.Join(src, "f"), []byte("a"), 0o644); err != nil {
		t.Fatal(err)
	}
	run("add", ".")
	run("commit", "-m", "a")
	olderThanWindow := run("rev-parse", "HEAD")

	// fill main past the ancestry window RefContainsCommit fetches
	for i := 0; i < maxAncestryDepth+5; i++ {
		if err := os.WriteFile(filepath.Join(src, "f"), []byte(fmt.Sprintf("f%d", i)), 0o644); err != nil {
			t.Fatal(err)
		}
		run("add", ".")
		run("commit", "-m", fmt.Sprintf("fill %d", i))
	}
	tip := run("rev-parse", "HEAD")
	ancestor := run("rev-parse", "HEAD~1")

	// a commit that exists in the repository but not on main
	run("checkout", "-b", "dev")
	if err := os.WriteFile(filepath.Join(src, "f"), []byte("c"), 0o644); err != nil {
		t.Fatal(err)
	}
	run("add", ".")
	run("commit", "-m", "c")
	offBranch := run("rev-parse", "HEAD")
	run("checkout", "main")

	for _, tt := range []struct {
		name string
		sha  string
		want bool
	}{
		{"the ref tip", tip, true},
		{"an ancestor of the tip", ancestor, true},
		{"a commit older than the ancestry window", olderThanWindow, false},
		{"a commit on another branch", offBranch, false},
		{"a commit the repo never had", strings.Repeat("1", 40), false},
	} {
		t.Run(tt.name, func(t *testing.T) {
			got, err := RefContainsCommit(context.Background(), src, "refs/heads/main", tt.sha)
			if got != tt.want {
				t.Fatalf("RefContainsCommit() = %t, err = %v, want %t", got, err, tt.want)
			}
		})
	}
}

// hangingKnot keeps the child process alive until the client closes its socket
func hangingKnot(t *testing.T) (url string, asked, dropped <-chan struct{}) {
	t.Helper()

	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ln.Close() })

	askedCh := make(chan struct{})
	droppedCh := make(chan struct{})
	var askOnce, dropOnce sync.Once

	go func() {
		for {
			conn, err := ln.Accept()
			if err != nil {
				return
			}
			go func() {
				defer conn.Close()
				br := bufio.NewReader(conn)
				for {
					line, err := br.ReadString('\n')
					if err != nil {
						return
					}
					if strings.TrimSpace(line) == "" {
						break
					}
				}
				io.WriteString(conn, "HTTP/1.1 200 OK\r\n"+
					"Content-Type: application/x-git-upload-pack-advertisement\r\n"+
					"Transfer-Encoding: chunked\r\n\r\n")
				askOnce.Do(func() { close(askedCh) })
				// this ends only when the child process dies
				io.Copy(io.Discard, br)
				dropOnce.Do(func() { close(droppedCh) })
			}()
		}
	}()

	return "http://" + ln.Addr().String(), askedCh, droppedCh
}

func TestRunCancelKillsRemoteHelper(t *testing.T) {
	knot, asked, dropped := hangingKnot(t)
	path := filepath.Join(t.TempDir(), "repo")

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	done := make(chan error, 1)
	go func() { done <- Run(ctx, "clone", "--no-checkout", "--depth=1", knot+"/repo.git", path) }()

	select {
	case <-asked:
	case err := <-done:
		t.Fatalf("clone returned before it reached the knot: %v", err)
	case <-time.After(30 * time.Second):
		t.Fatal("git never asked the knot for refs")
	}

	cancel()

	select {
	case err := <-done:
		if err == nil {
			t.Fatal("a cancelled clone reported success")
		}
	case <-time.After(15 * time.Second):
		t.Fatal("Run is still blocked after cancellation: a surviving helper holds the inherited stderr pipe open")
	}

	select {
	case <-dropped:
	case <-time.After(15 * time.Second):
		t.Fatal("git-remote-http outlived the cancelled clone")
	}
}

func TestSparseSyncCancelReleasesRepoLock(t *testing.T) {
	knot, asked, _ := hangingKnot(t)
	path := filepath.Join(t.TempDir(), "repo")

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	done := make(chan error, 1)
	go func() { done <- SparseSync(ctx, knot+"/repo.git", path, "HEAD", testSparseDir) }()

	select {
	case <-asked:
	case err := <-done:
		t.Fatalf("sync returned before it reached the knot: %v", err)
	case <-time.After(30 * time.Second):
		t.Fatal("git never asked the knot for refs")
	}

	cancel()

	select {
	case <-done:
	case <-time.After(15 * time.Second):
		t.Fatal("SparseSync is still blocked after cancellation")
	}

	// another request must be able to use this repo
	dead, cancelDead := context.WithCancel(context.Background())
	cancelDead()

	next := make(chan error, 1)
	go func() { next <- SparseSync(dead, knot+"/repo.git", path, "HEAD", testSparseDir) }()

	select {
	case <-next:
	case <-time.After(15 * time.Second):
		t.Fatal("a cancelled sync never released the repo lock")
	}
}
