package server

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/glebarez/sqlite"
	gogit "github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/object"
	"github.com/pingcap/agent-git-service/internal/db"
	"github.com/pingcap/agent-git-service/internal/gitstore"
	"github.com/pingcap/agent-git-service/internal/service"
	"gorm.io/gorm"
)

func TestPushRepositoryMirrorAcceptsBareTreePath(t *testing.T) {
	ctx := t.Context()
	root := t.TempDir()
	sourceDir := filepath.Join(root, "source.git")
	source, err := gogit.PlainInit(sourceDir, true)
	if err != nil {
		t.Fatalf("PlainInit(source): %v", err)
	}
	commitHash := writeBackslashPathCommit(t, source)

	store, err := gitstore.New(filepath.Join(root, "repositories"))
	if err != nil {
		t.Fatalf("gitstore.New: %v", err)
	}
	if err := initRepositoryMirrorFixture(t, ctx, store, "owner/target", "main", false); err != nil {
		t.Fatalf("Store.Init(target): %v", err)
	}
	srv := newRepositoryMirrorTestServer(t, store, "owner/target")
	if err := srv.PushRepositoryMirror(ctx, RepositoryMirrorPushRequest{
		RepositoryFullName: "owner/target",
		SourceGitDir:       sourceDir,
		RefSpecs:           []string{"+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"},
	}); err != nil {
		t.Fatalf("PushRepositoryMirror: %v", err)
	}

	targetDir, err := store.GetRepoPath(ctx, "owner/target")
	if err != nil {
		t.Fatalf("GetRepoPath(target): %v", err)
	}
	target, err := gogit.PlainOpen(targetDir)
	if err != nil {
		t.Fatalf("PlainOpen(target): %v", err)
	}
	ref, err := target.Reference(plumbing.NewBranchReferenceName("main"), true)
	if err != nil {
		t.Fatalf("Reference(main): %v", err)
	}
	if ref.Hash() != commitHash {
		t.Fatalf("target main = %s, want %s", ref.Hash(), commitHash)
	}
}

func TestPushRepositoryMirrorIgnoresLegacySealedState(t *testing.T) {
	ctx := t.Context()
	root := t.TempDir()
	sourceDir := filepath.Join(root, "source.git")
	source, err := gogit.PlainInit(sourceDir, true)
	if err != nil {
		t.Fatalf("PlainInit(source): %v", err)
	}
	commitHash := writeBackslashPathCommit(t, source)

	store, err := gitstore.New(filepath.Join(root, "repositories"))
	if err != nil {
		t.Fatalf("gitstore.New: %v", err)
	}
	if err := initRepositoryMirrorFixture(t, ctx, store, "owner/target", "main", true); err != nil {
		t.Fatalf("Store.Init(target): %v", err)
	}
	srv := newRepositoryMirrorTestServer(t, store, "owner/target")
	repository, err := srv.deps.SvcDeps.LookupRepoIdentity(ctx, "owner/target")
	if err != nil {
		t.Fatalf("LookupRepoIdentity: %v", err)
	}
	if err := srv.deps.SvcDeps.EnsureRepository(ctx, repository.ID); err != nil {
		t.Fatalf("EnsureRepository: %v", err)
	}
	legacy := srv.deps.SvcDeps.DB.Model(&db.RepositoryMigrationFreeze{}).
		Where("repository_id = ?", repository.ID).
		Updates(map[string]any{"state": "sealed", "epoch": 7})
	if legacy.Error != nil || legacy.RowsAffected != 1 {
		t.Fatalf("update legacy sealed state: rows=%d err=%v", legacy.RowsAffected, legacy.Error)
	}
	err = srv.PushRepositoryMirror(ctx, RepositoryMirrorPushRequest{
		RepositoryFullName: repository.FullName,
		SourceGitDir:       sourceDir,
		RefSpecs:           []string{"+refs/heads/*:refs/heads/*"},
	})
	if err != nil {
		t.Fatalf("PushRepositoryMirror with legacy sealed state: %v", err)
	}
	after, err := store.HeadSHA(ctx, repository.FullName, repository.DefaultBranch)
	if err != nil {
		t.Fatalf("HeadSHA after rejected mirror: %v", err)
	}
	if after != commitHash.String() {
		t.Fatalf("mirror head = %s, want source %s", after, commitHash.String())
	}
}

func TestPushRepositoryMirrorRunsTargetReceiveHooksAndSanitizesPaths(t *testing.T) {
	ctx := t.Context()
	root := t.TempDir()
	sourceDir := filepath.Join(root, "secret-source.git")
	source, err := gogit.PlainInit(sourceDir, true)
	if err != nil {
		t.Fatalf("PlainInit(source): %v", err)
	}
	writeBackslashPathCommit(t, source)

	store, err := gitstore.New(filepath.Join(root, "secret-repositories"))
	if err != nil {
		t.Fatalf("gitstore.New: %v", err)
	}
	if err := initRepositoryMirrorFixture(t, ctx, store, "owner/target", "main", false); err != nil {
		t.Fatalf("Store.Init(target): %v", err)
	}
	targetDir, err := store.GetRepoPath(ctx, "owner/target")
	if err != nil {
		t.Fatalf("GetRepoPath(target): %v", err)
	}
	const processSecret = "must-not-reach-target-hook"
	t.Setenv("AGS_MIRROR_TEST_SECRET", processSecret)
	hook := filepath.Join(targetDir, "hooks", "pre-receive")
	if err := os.WriteFile(hook, []byte("#!/bin/sh\nif [ -n \"$AGS_MIRROR_TEST_SECRET\" ]; then echo \"leaked:$AGS_MIRROR_TEST_SECRET\" >&2; else echo rejected-by-target-hook >&2; fi\nexit 1\n"), 0o750); err != nil {
		t.Fatalf("WriteFile(pre-receive): %v", err)
	}

	srv := newRepositoryMirrorTestServer(t, store, "owner/target")
	err = srv.PushRepositoryMirror(ctx, RepositoryMirrorPushRequest{
		RepositoryFullName: "owner/target",
		SourceGitDir:       sourceDir,
		RefSpecs:           []string{"+refs/heads/*:refs/heads/*"},
	})
	if err == nil {
		t.Fatal("PushRepositoryMirror unexpectedly succeeded")
	}
	message := err.Error()
	if !strings.Contains(message, "rejected-by-target-hook") {
		t.Fatalf("PushRepositoryMirror error = %q, want target hook output", message)
	}
	if strings.Contains(message, processSecret) {
		t.Fatalf("PushRepositoryMirror error leaked process environment secret: %s", message)
	}
	for _, secret := range []string{sourceDir, targetDir} {
		if strings.Contains(message, secret) {
			t.Fatalf("PushRepositoryMirror error leaked path %q: %s", secret, message)
		}
	}
}

func initRepositoryMirrorFixture(t *testing.T, ctx context.Context, store *gitstore.Store, fullName, defaultBranch string, seed bool) error {
	t.Helper()
	const repositoryID = 1
	database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
	if err != nil {
		return err
	}
	if err := database.AutoMigrate(&db.Repository{}, &db.RepositoryMigrationFreeze{}); err != nil {
		return err
	}
	_, name, ok := strings.Cut(fullName, "/")
	if !ok || name == "" {
		return fmt.Errorf("invalid repository full name %q", fullName)
	}
	if err := database.Create(&db.Repository{ID: repositoryID, Name: name, FullName: fullName, DefaultBranch: defaultBranch}).Error; err != nil {
		return err
	}
	svc, err := service.New(service.Config{DB: database, Git: store})
	if err != nil {
		return err
	}
	if err := svc.EnsureRepository(ctx, repositoryID); err != nil {
		return err
	}
	if !seed {
		return nil
	}
	path, err := store.GetRepoPath(ctx, fullName)
	if err != nil {
		return err
	}
	repository, err := gogit.PlainOpen(path)
	if err != nil {
		return err
	}
	writeBackslashPathCommit(t, repository)
	return nil
}

func TestBoundedRepositoryMirrorOutputRedactsBeforeLimit(t *testing.T) {
	const (
		source      = "/private/secret/source/repository.git"
		target      = "/private/secret/target/repository.git"
		prefixBytes = repositoryMirrorPushOutputLimit - len("[source]")
	)
	prefix := strings.Repeat("x", prefixBytes)
	tests := []struct {
		name        string
		secret      string
		replacement string
		chunks      []string
	}{
		{name: "source single write", secret: source, replacement: "[source]", chunks: []string{prefix + source}},
		{name: "target single write", secret: target, replacement: "[target]", chunks: []string{prefix + target}},
		{name: "source multiple writes", secret: source, replacement: "[source]", chunks: []string{prefix + source[:11], source[11:]}},
		{name: "target multiple writes", secret: target, replacement: "[target]", chunks: []string{prefix, target[:17], target[17:]}},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			output := newBoundedRepositoryMirrorOutput(source, target)
			stream := output.Writer()
			for _, chunk := range test.chunks {
				if written, err := stream.Write([]byte(chunk)); err != nil || written != len(chunk) {
					t.Fatalf("Write = (%d, %v), want (%d, nil)", written, err, len(chunk))
				}
			}
			got := output.String()
			want := prefix + test.replacement
			if got != want {
				t.Fatalf("String = %q, want %q", got, want)
			}
			if strings.Contains(got, test.secret) {
				t.Fatalf("String leaked secret %q: %q", test.secret, got)
			}
		})
	}
}

func TestBoundedRepositoryMirrorOutputFailsClosedForPartialPathAndDrains(t *testing.T) {
	const (
		source = "/private/secret/source/repository.git"
		target = "/private/secret/target/repository.git"
	)
	output := newBoundedRepositoryMirrorOutput(source, target)
	stdout := output.Writer()
	prefix := strings.Repeat("x", repositoryMirrorPushOutputLimit-len("[path]"))
	partial := source[:len(source)/2]
	if _, err := stdout.Write([]byte(prefix + partial)); err != nil {
		t.Fatalf("stdout.Write: %v", err)
	}
	got := output.String()
	if got != prefix+"[path]" {
		t.Fatalf("String = %q, want bounded fail-closed partial redaction", got)
	}
	if strings.Contains(got, partial) || len(got) > repositoryMirrorPushOutputLimit {
		t.Fatalf("String leaked partial path or exceeded limit: len=%d value=%q", len(got), got)
	}

	drain := newBoundedRepositoryMirrorOutput(source, target)
	stderr := drain.Writer()
	large := strings.Repeat("y", 1<<20)
	if written, err := stderr.Write([]byte(large)); err != nil || written != len(large) {
		t.Fatalf("stderr.Write = (%d, %v), want (%d, nil)", written, err, len(large))
	}
	if got := drain.String(); len(got) != repositoryMirrorPushOutputLimit {
		t.Fatalf("drained String length = %d, want %d", len(got), repositoryMirrorPushOutputLimit)
	}
}

func TestBoundedRepositoryMirrorOutputRedactsConcurrentStreams(t *testing.T) {
	const (
		source = "/private/secret/source/repository.git"
		target = "/private/secret/target/repository.git"
	)
	output := newBoundedRepositoryMirrorOutput(source, target)
	stdout := output.Writer()
	stderr := output.Writer()
	var writers sync.WaitGroup
	writers.Add(2)
	go func() {
		defer writers.Done()
		for range 100 {
			_, _ = stdout.Write([]byte("source=" + source + "\n"))
		}
	}()
	go func() {
		defer writers.Done()
		for range 100 {
			_, _ = stderr.Write([]byte("target=" + target + "\n"))
		}
	}()
	writers.Wait()
	got := output.String()
	if strings.Contains(got, source) || strings.Contains(got, target) || len(got) > repositoryMirrorPushOutputLimit {
		t.Fatalf("concurrent streams leaked paths or exceeded limit: len=%d value=%q", len(got), got)
	}
}

func TestPushRepositoryMirrorCancellationKillsGitProcessGroupBeforeFenceRelease(t *testing.T) {
	root := t.TempDir()
	sourceDir := filepath.Join(root, "source.git")
	source, err := gogit.PlainInit(sourceDir, true)
	if err != nil {
		t.Fatalf("PlainInit(source): %v", err)
	}
	writeBackslashPathCommit(t, source)

	store, err := gitstore.New(filepath.Join(root, "repositories"))
	if err != nil {
		t.Fatalf("gitstore.New: %v", err)
	}
	if err := initRepositoryMirrorFixture(t, t.Context(), store, "owner/target", "main", false); err != nil {
		t.Fatalf("Store.Init(target): %v", err)
	}
	srv := newRepositoryMirrorTestServer(t, store, "owner/target")
	repository, err := srv.deps.SvcDeps.LookupRepoIdentity(t.Context(), "owner/target")
	if err != nil {
		t.Fatalf("LookupRepoIdentity: %v", err)
	}
	targetDir, err := store.GetRepoPath(t.Context(), "owner/target")
	if err != nil {
		t.Fatalf("GetRepoPath(target): %v", err)
	}
	started := filepath.Join(root, "hook-started")
	hook := "#!/bin/sh\necho started > " + shellQuoteRepositoryMirrorTest(started) + "\nwhile :; do sleep 1; done\n"
	if err := os.WriteFile(filepath.Join(targetDir, "hooks", "pre-receive"), []byte(hook), 0o750); err != nil {
		t.Fatalf("WriteFile(pre-receive): %v", err)
	}

	ctx, cancel := context.WithCancel(t.Context())
	defer cancel()
	done := make(chan error, 1)
	go func() {
		done <- srv.PushRepositoryMirror(ctx, RepositoryMirrorPushRequest{
			RepositoryFullName: "owner/target",
			SourceGitDir:       sourceDir,
			RefSpecs:           []string{"+refs/heads/*:refs/heads/*"},
		})
	}()
	waitForRepositoryMirrorTestPath(t, started)

	waitCtx, waitCancel := context.WithTimeout(t.Context(), 150*time.Millisecond)
	defer waitCancel()
	if err := srv.deps.SvcDeps.CreateRepositoryBranch(waitCtx, repository.ID, repository.FullName, "writer-probe", "main"); !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("repository writer while mirror child is active = %v, want deadline", err)
	}
	cancel()
	select {
	case err := <-done:
		if !errors.Is(err, context.Canceled) {
			t.Fatalf("PushRepositoryMirror cancellation = %v, want context canceled", err)
		}
	case <-time.After(10 * time.Second):
		t.Fatal("PushRepositoryMirror did not join the cancelled Git process group")
	}

	if _, err := srv.deps.SvcDeps.WriteRepositoryFile(t.Context(), repository, "main", "after-cancel.txt", "writer after cancel", []byte("joined\n"), ""); err != nil {
		t.Fatalf("repository writer after cancelled mirror joined: %v", err)
	}
}

func newRepositoryMirrorTestServer(t *testing.T, store *gitstore.Store, fullName string) *Server {
	t.Helper()
	database, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "ags.db")), &gorm.Config{})
	if err != nil {
		t.Fatalf("open repository identity database: %v", err)
	}
	if err := database.AutoMigrate(&db.Repository{}, &db.RepositoryMigrationFreeze{}, &db.RepositoryPushRecovery{}, &db.RepositoryPushOutbox{}, &db.RepositoryWebhookFanout{}, &db.RepoRedirect{}, &db.Release{}); err != nil {
		t.Fatalf("migrate repository identity database: %v", err)
	}
	_, name, ok := strings.Cut(fullName, "/")
	if !ok || name == "" {
		t.Fatalf("invalid repository full name %q", fullName)
	}
	if err := database.Create(&db.Repository{Name: name, FullName: fullName, DefaultBranch: "main"}).Error; err != nil {
		t.Fatalf("create repository identity: %v", err)
	}
	svc, err := service.New(service.Config{DB: database, Git: store})
	if err != nil {
		t.Fatalf("service.New: %v", err)
	}
	return &Server{deps: &bootstrapDeps{Store: store, SvcDeps: svc}}
}

func waitForRepositoryMirrorTestPath(t *testing.T, path string) {
	t.Helper()
	deadline := time.Now().Add(10 * 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)
}

func shellQuoteRepositoryMirrorTest(value string) string {
	return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}

func writeBackslashPathCommit(t *testing.T, repo *gogit.Repository) plumbing.Hash {
	t.Helper()
	blob := &plumbing.MemoryObject{}
	blob.SetType(plumbing.BlobObject)
	if _, err := blob.Write([]byte("content")); err != nil {
		t.Fatalf("blob.Write: %v", err)
	}
	blobHash, err := repo.Storer.SetEncodedObject(blob)
	if err != nil {
		t.Fatalf("SetEncodedObject(blob): %v", err)
	}
	tree := &plumbing.MemoryObject{}
	if err := (&object.Tree{Entries: []object.TreeEntry{{Name: `\`, Mode: 0o100644, Hash: blobHash}}}).Encode(tree); err != nil {
		t.Fatalf("Tree.Encode: %v", err)
	}
	treeHash, err := repo.Storer.SetEncodedObject(tree)
	if err != nil {
		t.Fatalf("SetEncodedObject(tree): %v", err)
	}
	commit := &plumbing.MemoryObject{}
	if err := (&object.Commit{
		Author:    object.Signature{Name: "AGS Test", Email: "ags@example.local", When: time.Now().UTC()},
		Committer: object.Signature{Name: "AGS Test", Email: "ags@example.local", When: time.Now().UTC()},
		Message:   "special path",
		TreeHash:  treeHash,
	}).Encode(commit); err != nil {
		t.Fatalf("Commit.Encode: %v", err)
	}
	commitHash, err := repo.Storer.SetEncodedObject(commit)
	if err != nil {
		t.Fatalf("SetEncodedObject(commit): %v", err)
	}
	mainRef := plumbing.NewBranchReferenceName("main")
	if err := repo.Storer.SetReference(plumbing.NewHashReference(mainRef, commitHash)); err != nil {
		t.Fatalf("SetReference(main): %v", err)
	}
	if err := repo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, mainRef)); err != nil {
		t.Fatalf("SetReference(HEAD): %v", err)
	}
	return commitHash
}
