package githttp

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

	"github.com/glebarez/sqlite"
	"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 TestPushOutboxFallbackCoalescesConcurrentWakes(t *testing.T) {
	database, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "outbox.db")), &gorm.Config{})
	if err != nil {
		t.Fatalf("open database: %v", err)
	}
	if err := database.AutoMigrate(&db.Repository{}, &db.Label{}, &db.RepositoryMigrationFreeze{}, &db.RepositoryPushRecovery{}, &db.RepositoryPushOutbox{}, &db.RepositoryWebhookFanout{}, &db.Workflow{}); err != nil {
		t.Fatalf("AutoMigrate: %v", err)
	}
	store, err := gitstore.New(t.TempDir())
	if err != nil {
		t.Fatalf("gitstore.New: %v", err)
	}
	svc, err := service.New(service.Config{DB: database, Git: store})
	if err != nil {
		t.Fatalf("service.New: %v", err)
	}
	handler := New(store, svc)
	const repositoryCount = pushOutboxPageSize + 5
	for index := 1; index <= repositoryCount; index++ {
		repository := db.Repository{ID: uint(index), Name: fmt.Sprintf("repo-%d", index), FullName: fmt.Sprintf("owner/repo-%d", index), DefaultBranch: "main"}
		if err := database.Create(&repository).Error; err != nil {
			t.Fatalf("create repository %d: %v", index, err)
		}
		if err := svc.EnsureRepository(t.Context(), repository.ID); err != nil {
			t.Fatalf("EnsureRepository(%d): %v", index, err)
		}
		outbox := db.RepositoryPushOutbox{RepositoryID: repository.ID, OperationID: fmt.Sprintf("operation-%d", index), EventKey: "post_push",
			OccurredAt: time.Now().UTC(), RefUpdatesJSON: db.LargeText("[]"), State: "pending", NextAttemptAt: time.Now().UTC().Add(-time.Minute)}
		if err := database.Create(&outbox).Error; err != nil {
			t.Fatalf("create outbox %d: %v", index, err)
		}
	}

	entered := make(chan struct{})
	release := make(chan struct{})
	var once sync.Once
	if err := database.Callback().Query().Before("gorm:query").Register("test:block_push_recovery", func(tx *gorm.DB) {
		if tx.Statement != nil && tx.Statement.Table == "repository_push_recovery" {
			once.Do(func() { close(entered) })
			<-release
		}
	}); err != nil {
		t.Fatalf("register query callback: %v", err)
	}

	handler.kickPushOutbox(1)
	select {
	case <-entered:
	case <-time.After(2 * time.Second):
		t.Fatal("fallback worker did not start")
	}
	for index := 0; index < 100; index++ {
		handler.kickPushOutbox(uint(index + 2))
	}
	handler.pushOutboxFallbackMu.Lock()
	running, pending := handler.pushOutboxFallbackRunning, handler.pushOutboxFallbackPending
	handler.pushOutboxFallbackMu.Unlock()
	if !running || !pending {
		t.Fatalf("fallback coalescing state = running:%v pending:%v, want one running generation with pending work", running, pending)
	}
	close(release)
	done := make(chan struct{})
	go func() {
		svc.Wg.Wait()
		close(done)
	}()
	select {
	case <-done:
	case <-time.After(2 * time.Second):
		t.Fatal("fallback worker did not exit")
	}
	var remaining int64
	if err := database.Model(&db.RepositoryPushOutbox{}).Count(&remaining).Error; err != nil {
		t.Fatalf("count outbox rows: %v", err)
	}
	if remaining != 0 {
		t.Fatalf("fallback left %d of %d multi-repository rows after bounded page drain", remaining, repositoryCount)
	}
}

func TestPushOutboxHydrationLimitRetainsDurableRow(t *testing.T) {
	database, _, _, handler, repository := newPushOutboxFailureFixture(t, 101)
	const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
	t.Setenv("PATH", writeOversizedPushLogGit(t, sha))
	now := time.Now().UTC()
	outbox := db.RepositoryPushOutbox{
		RepositoryID: repository.ID, OperationID: "operation-hydration-limit", EventKey: "post_push", OccurredAt: now,
		RefUpdatesJSON: db.LargeText(fmt.Sprintf(`[{"ref":"refs/heads/main","before":"","after":%q,"commit_shas":[%q]}]`, sha, sha)),
		State:          "pending", NextAttemptAt: now.Add(-time.Minute),
	}
	if err := database.Create(&outbox).Error; err != nil {
		t.Fatalf("create outbox: %v", err)
	}
	claimed, _ := handler.processPushOutboxPage(t.Context(), repository.ID)
	if claimed != 1 {
		t.Fatalf("claimed rows = %d, want 1", claimed)
	}
	var retained db.RepositoryPushOutbox
	if err := database.First(&retained, outbox.ID).Error; err != nil {
		t.Fatalf("load retained outbox: %v", err)
	}
	if retained.Attempts != 1 || retained.LeaseToken != "" || retained.LeaseUntil != nil || !strings.Contains(retained.LastError, "file limit") || !retained.NextAttemptAt.After(now) {
		t.Fatalf("oversized hydration outbox was not durably retried: %+v", retained)
	}
}

func TestPushOutboxAncestorFailureRetainsDurableRow(t *testing.T) {
	database, _, _, handler, repository := newPushOutboxFailureFixture(t, 102)
	directory := t.TempDir()
	if err := os.WriteFile(filepath.Join(directory, "git"), []byte("#!/bin/sh\nexit 128\n"), 0o700); err != nil {
		t.Fatalf("write failing fake git: %v", err)
	}
	t.Setenv("PATH", directory)
	const before = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
	const after = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
	now := time.Now().UTC()
	outbox := db.RepositoryPushOutbox{
		RepositoryID: repository.ID, OperationID: "operation-ancestor-failure", EventKey: "post_push", OccurredAt: now,
		RefUpdatesJSON: db.LargeText(fmt.Sprintf(`[{"ref":"refs/heads/main","before":%q,"after":%q,"commit_shas":[%q]}]`, before, after, after)),
		State:          "pending", NextAttemptAt: now.Add(-time.Minute),
	}
	if err := database.Create(&outbox).Error; err != nil {
		t.Fatalf("create outbox: %v", err)
	}
	claimed, _ := handler.processPushOutboxPage(t.Context(), repository.ID)
	if claimed != 1 {
		t.Fatalf("claimed rows = %d, want 1", claimed)
	}
	var retained db.RepositoryPushOutbox
	if err := database.First(&retained, outbox.ID).Error; err != nil {
		t.Fatalf("load retained outbox: %v", err)
	}
	if retained.Attempts != 1 || retained.LeaseToken != "" || retained.LeaseUntil != nil || !strings.Contains(retained.LastError, "classify pushed ref") || !retained.NextAttemptAt.After(now) {
		t.Fatalf("ancestor failure outbox was not durably retried: %+v", retained)
	}
}

func TestPushOutboxFallbackSkipsMalformedMarkerPage(t *testing.T) {
	database, store, svc, handler, repository := newPushOutboxFailureFixture(t, 103)
	now := time.Now().UTC()
	for repositoryID := uint(1); repositoryID <= pushOutboxPageSize; repositoryID++ {
		marker := db.RepositoryPushRecovery{
			RepositoryID: repositoryID, OperationID: fmt.Sprintf("malformed-marker-%d", repositoryID), Epoch: 1,
			OccurredAt: now, CommandsJSON: db.LargeText("not-json"), State: "pending", NextAttemptAt: now.Add(-2 * time.Minute),
		}
		if err := database.Create(&marker).Error; err != nil {
			t.Fatalf("create malformed marker %d: %v", repositoryID, err)
		}
	}
	var freeze db.RepositoryMigrationFreeze
	if err := database.First(&freeze, "repository_id = ?", repository.ID).Error; err != nil {
		t.Fatalf("load freeze: %v", err)
	}
	repositoryPath, err := store.GetRepoPath(t.Context(), repository.FullName)
	if err != nil {
		t.Fatalf("GetRepoPath: %v", err)
	}
	head := seedPushOutboxRepository(t, repositoryPath)
	valid := db.RepositoryPushRecovery{
		RepositoryID: repository.ID, OperationID: "valid-marker-after-malformed-page", Epoch: freeze.Epoch,
		OccurredAt: now, CommandsJSON: db.LargeText(fmt.Sprintf(`[{"ref":"refs/heads/main","before":%q,"after":%q}]`, head, head)),
		State: "pending", NextAttemptAt: now.Add(-time.Minute),
	}
	if err := database.Create(&valid).Error; err != nil {
		t.Fatalf("create valid marker: %v", err)
	}
	handler.kickPushOutbox(repository.ID)
	t.Cleanup(func() {
		handler.pushOutboxFallbackMu.Lock()
		defer handler.pushOutboxFallbackMu.Unlock()
		if handler.pushOutboxFallbackTimer != nil {
			handler.pushOutboxFallbackTimer.Stop()
		}
	})
	done := make(chan struct{})
	go func() {
		svc.Wg.Wait()
		close(done)
	}()
	select {
	case <-done:
	case <-time.After(2 * time.Second):
		t.Fatal("fallback marker recovery did not finish")
	}
	var validRemaining int64
	if err := database.Model(&db.RepositoryPushRecovery{}).Where("repository_id = ?", repository.ID).Count(&validRemaining).Error; err != nil {
		t.Fatalf("count valid marker: %v", err)
	}
	if validRemaining != 0 {
		t.Fatal("valid marker behind malformed page was starved")
	}
}

func TestPushOutboxFallbackWakesAtFutureRetry(t *testing.T) {
	database, _, _, handler, repository := newPushOutboxFailureFixture(t, 104)
	t.Cleanup(func() {
		handler.pushOutboxFallbackMu.Lock()
		defer handler.pushOutboxFallbackMu.Unlock()
		if handler.pushOutboxFallbackTimer != nil {
			handler.pushOutboxFallbackTimer.Stop()
		}
	})
	now := time.Now().UTC()
	outbox := db.RepositoryPushOutbox{
		RepositoryID: repository.ID, OperationID: "future-outbox-retry", EventKey: "post_push", OccurredAt: now,
		RefUpdatesJSON: db.LargeText("[]"), State: "pending", NextAttemptAt: now.Add(100 * time.Millisecond),
	}
	if err := database.Create(&outbox).Error; err != nil {
		t.Fatalf("create future outbox: %v", err)
	}
	handler.kickPushOutbox(repository.ID)
	deadline := time.Now().Add(7 * time.Second)
	for {
		var remaining int64
		if err := database.Model(&db.RepositoryPushOutbox{}).Where("id = ?", outbox.ID).Count(&remaining).Error; err != nil {
			t.Fatalf("count future outbox: %v", err)
		}
		if remaining == 0 {
			break
		}
		if time.Now().After(deadline) {
			t.Fatal("nil-lifecycle fallback did not wake for future push outbox")
		}
		time.Sleep(25 * time.Millisecond)
	}
}

func seedPushOutboxRepository(t *testing.T, repositoryPath string) string {
	t.Helper()
	run := func(stdin string, arguments ...string) string {
		command := exec.Command("git", append([]string{"--git-dir", repositoryPath}, arguments...)...)
		command.Stdin = strings.NewReader(stdin)
		command.Env = append(os.Environ(),
			"GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.invalid", "GIT_AUTHOR_DATE=2026-08-15T00:00:00Z",
			"GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.invalid", "GIT_COMMITTER_DATE=2026-08-15T00:00:00Z",
		)
		output, err := command.CombinedOutput()
		if err != nil {
			t.Fatalf("git %v: %v: %s", arguments, err, output)
		}
		return strings.TrimSpace(string(output))
	}
	tree := run("", "mktree")
	commit := run("seed\n", "commit-tree", tree)
	run("", "update-ref", "refs/heads/main", commit)
	run("", "symbolic-ref", "HEAD", "refs/heads/main")
	return commit
}

func newPushOutboxFailureFixture(t *testing.T, repositoryID uint) (*gorm.DB, *gitstore.Store, *service.Service, *Handler, db.Repository) {
	t.Helper()
	database, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "outbox-failure.db")), &gorm.Config{})
	if err != nil {
		t.Fatalf("open database: %v", err)
	}
	if err := database.AutoMigrate(&db.Repository{}, &db.Label{}, &db.RepositoryMigrationFreeze{}, &db.RepositoryPushRecovery{}, &db.RepositoryPushOutbox{}, &db.RepositoryWebhookFanout{}, &db.Workflow{}); err != nil {
		t.Fatalf("AutoMigrate: %v", err)
	}
	store, err := gitstore.New(t.TempDir())
	if err != nil {
		t.Fatalf("gitstore.New: %v", err)
	}
	svc, err := service.New(service.Config{DB: database, Git: store})
	if err != nil {
		t.Fatalf("service.New: %v", err)
	}
	repository := db.Repository{ID: repositoryID, Name: "repo", FullName: fmt.Sprintf("owner/repo-%d", repositoryID), DefaultBranch: "main"}
	if err := database.Create(&repository).Error; err != nil {
		t.Fatalf("create repository: %v", err)
	}
	if err := svc.EnsureRepository(t.Context(), repository.ID); err != nil {
		t.Fatalf("EnsureRepository: %v", err)
	}
	return database, store, svc, New(store, svc), repository
}
