//go:build !windows

package service

import (
	"context"
	"errors"
	"os"
	"os/exec"
	"path/filepath"
	"testing"
	"time"

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

func TestMergePRRecordCrossRepositoryCancellationDoesNotPublish(t *testing.T) {
	for _, test := range []struct {
		name string
		mode string
	}{
		{name: "compute", mode: "compute-cancel"},
		{name: "final publish", mode: "final-cancel"},
	} {
		t.Run(test.name, func(t *testing.T) {
			installCrossRepositoryMergeGitWrapper(t)
			fixture := newCrossRepositoryUpdateFixture(t, 10, 20)
			prepareCrossRepositoryMerge(t, &fixture)
			before := captureCrossRepositoryMergeState(t, fixture)
			started := filepath.Join(t.TempDir(), "started")
			t.Setenv("MERGE_HOOK_MODE", test.mode)
			t.Setenv("MERGE_HOOK_STARTED", started)

			ctx, cancel := context.WithCancel(t.Context())
			result := make(chan error, 1)
			go func() {
				result <- fixture.service.mergePRRecordWithActor(ctx, db.User{Login: "merge-actor"}, &fixture.pr, "merge", "canceled fork merge")
			}()
			waitForCrossRepositoryMergeHook(t, started)
			cancel()
			select {
			case err := <-result:
				if !errors.Is(err, context.Canceled) {
					t.Fatalf("mergePRRecordWithActor = %v, want context.Canceled", err)
				}
			case <-time.After(5 * time.Second):
				t.Fatal("fork merge did not join canceled Git process tree")
			}
			assertCrossRepositoryMergeUnchanged(t, fixture, before)
		})
	}
}

func TestMergePRRecordCrossRepositoryCASKeepsConcurrentWinner(t *testing.T) {
	installCrossRepositoryMergeGitWrapper(t)
	fixture := newCrossRepositoryUpdateFixture(t, 10, 20)
	prepareCrossRepositoryMerge(t, &fixture)
	before := captureCrossRepositoryMergeState(t, fixture)
	if fixture.pr.HeadSHA == before.baseSHA {
		t.Fatal("CAS winner fixture did not diverge from base")
	}
	t.Setenv("MERGE_HOOK_MODE", "cas-loser")
	t.Setenv("MERGE_HOOK_WINNER", fixture.pr.HeadSHA)
	t.Setenv("MERGE_HOOK_EXPECTED", before.baseSHA)

	err := fixture.service.mergePRRecordWithActor(t.Context(), db.User{Login: "merge-actor"}, &fixture.pr, "merge", "fork CAS loser")
	if !errors.Is(err, ErrConflict) {
		t.Fatalf("mergePRRecordWithActor = %v, want conflict", err)
	}
	before.baseSHA = fixture.pr.HeadSHA
	assertCrossRepositoryMergeUnchanged(t, fixture, before)
}

func TestMergePRRecordCrossRepositoryProjectionRollbackKeepsConcurrentWinner(t *testing.T) {
	realGit, err := exec.LookPath("git")
	if err != nil {
		t.Fatalf("find git: %v", err)
	}
	fixture := newCrossRepositoryUpdateFixture(t, 10, 20)
	prepareCrossRepositoryMerge(t, &fixture)
	before := captureCrossRepositoryMergeState(t, fixture)
	repositoryPath, err := fixture.service.Git.GetRepoPath(t.Context(), fixture.base.FullName)
	if err != nil {
		t.Fatalf("base repository path: %v", err)
	}
	projectionFailure := errors.New("injected fork projection failure")
	var winnerErr error
	if err := fixture.service.DB.Callback().Update().Before("gorm:update").Register("fork-projection-concurrent-winner", func(tx *gorm.DB) {
		if tx.Statement.Table != "pull_requests" {
			return
		}
		publishedSHA, lookupErr := fixture.service.Git.HeadSHA(t.Context(), fixture.base.FullName, "main")
		if lookupErr != nil {
			winnerErr = lookupErr
			tx.AddError(lookupErr)
			return
		}
		_, updateErr := exec.Command(realGit, "--git-dir", repositoryPath, "update-ref", "refs/heads/main", fixture.pr.HeadSHA, publishedSHA).CombinedOutput()
		if updateErr != nil {
			winnerErr = updateErr
			tx.AddError(updateErr)
			return
		}
		tx.AddError(projectionFailure)
	}); err != nil {
		t.Fatalf("register projection winner: %v", err)
	}

	err = fixture.service.mergePRRecordWithActor(t.Context(), db.User{Login: "merge-actor"}, &fixture.pr, "merge", "fork projection loser")
	if winnerErr != nil {
		t.Fatalf("publish concurrent winner: %v", winnerErr)
	}
	if !errors.Is(err, projectionFailure) {
		t.Fatalf("mergePRRecordWithActor = %v, want projection failure", err)
	}
	before.baseSHA = fixture.pr.HeadSHA
	assertCrossRepositoryMergeUnchanged(t, fixture, before)
}

func installCrossRepositoryMergeGitWrapper(t *testing.T) {
	t.Helper()
	realGit, err := exec.LookPath("git")
	if err != nil {
		t.Fatalf("find git: %v", err)
	}
	directory := t.TempDir()
	wrapper := filepath.Join(directory, "git")
	script := `#!/bin/sh
case "$MERGE_HOOK_MODE:$3:$4" in
  compute-cancel:merge:*)
    : > "$MERGE_HOOK_STARTED"
    sleep 30
    ;;
  final-cancel:update-ref:refs/heads/main)
    : > "$MERGE_HOOK_STARTED"
    sleep 30
    ;;
  cas-loser:update-ref:refs/heads/main)
    "$REAL_GIT" --git-dir "$2" update-ref "$4" "$MERGE_HOOK_WINNER" "$MERGE_HOOK_EXPECTED" || exit $?
    ;;
esac
exec "$REAL_GIT" "$@"
`
	if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil {
		t.Fatalf("write Git wrapper: %v", err)
	}
	t.Setenv("REAL_GIT", realGit)
	t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH"))
}

func waitForCrossRepositoryMergeHook(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("Git hook did not start: %s", path)
}
