package cache

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"testing"
)

func TestConcurrentSearchSources(t *testing.T) {
	ctx := context.Background()
	path := filepath.Join(t.TempDir(), "cache.db")
	store, err := NewSQLiteStore(ctx, path)
	if err != nil {
		t.Fatalf("NewSQLiteStore returned error: %v", err)
	}
	defer store.Close()
	mustAddTestRepo(t, ctx, store, "fixture-a")
	for i := 0; i < 20; i++ {
		id := fmt.Sprintf("DOC-SCN1-%02d", i)
		mustUpsertGraph(t, ctx, store, SourceGraph{Source: testSource(id, "doc", "Search Doc "+id)})
	}

	var wg sync.WaitGroup
	errs := make(chan error, 2)
	for j := 0; j < 2; j++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			results, searchErr := store.SearchSources(ctx, SearchQuery{RepoID: "fixture-a", Query: "Search", Limit: 20})
			if searchErr != nil {
				errs <- searchErr
				return
			}
			if len(results) == 0 {
				errs <- fmt.Errorf("concurrent SearchSources returned 0 results")
			}
		}()
	}
	wg.Wait()
	close(errs)

	for e := range errs {
		t.Errorf("concurrent SearchSources error: %v", e)
	}
}

func TestInMemoryStoreUsesOneMigratedConnectionConcurrently(t *testing.T) {
	ctx := context.Background()
	store, err := NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if got := store.db.Stats().MaxOpenConnections; got != 1 {
		t.Fatalf("MaxOpenConnections=%d want 1 for :memory:", got)
	}
	if err := store.AddRepository(ctx, RepositoryBinding{RepoID: "memory-concurrent", Owner: "owner", Name: "repo"}); err != nil {
		t.Fatal(err)
	}

	var wg sync.WaitGroup
	errs := make(chan error, 32)
	for i := 0; i < 32; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 20; j++ {
				if _, err := store.ListRepositories(ctx); err != nil {
					errs <- err
					return
				}
				if _, _, err := store.GetSyncFrontier(ctx, "memory-concurrent", "issue", "updated_at_desc", "state=all"); err != nil {
					errs <- err
					return
				}
			}
		}()
	}
	wg.Wait()
	close(errs)
	for err := range errs {
		t.Fatalf("concurrent in-memory query: %v", err)
	}
}

func TestWriterHoldReadersUnblocked(t *testing.T) {
	ctx := context.Background()
	path := filepath.Join(t.TempDir(), "cache.db")
	store, err := NewSQLiteStore(ctx, path)
	if err != nil {
		t.Fatalf("NewSQLiteStore returned error: %v", err)
	}
	defer store.Close()
	mustAddTestRepo(t, ctx, store, "fixture-a")
	mustUpsertGraph(t, ctx, store, SourceGraph{Source: testSource("DOC-WHR", "doc", "Writer Hold Reader Doc")})

	readerOne, err := NewSQLiteStore(ctx, path)
	if err != nil {
		t.Fatalf("NewSQLiteStore readerOne returned error: %v", err)
	}
	defer readerOne.Close()
	readerTwo, err := NewSQLiteStore(ctx, path)
	if err != nil {
		t.Fatalf("NewSQLiteStore readerTwo returned error: %v", err)
	}
	defer readerTwo.Close()

	lease, err := store.AcquireWriter(ctx, WriterRequest{Operation: "sync-index", RepoID: "fixture-a"})
	if err != nil {
		t.Fatalf("AcquireWriter returned error: %v", err)
	}
	defer store.ReleaseWriter(ctx, lease)

	for i, reader := range []*SQLiteStore{readerOne, readerTwo} {
		source, readErr := reader.GetSourceScoped(ctx, "fixture-a", "DOC-WHR")
		if readErr != nil {
			t.Errorf("reader %d GetSourceScoped returned error: %v", i+1, readErr)
		}
		if source.RepoID != "fixture-a" || source.ID != "DOC-WHR" {
			t.Errorf("reader %d source = %#v", i+1, source)
		}
		results, searchErr := reader.SearchSources(ctx, SearchQuery{RepoID: "fixture-a", Query: "Writer Hold", Limit: 10})
		if searchErr != nil {
			t.Errorf("reader %d SearchSources returned error: %v", i+1, searchErr)
		}
		if len(results) == 0 {
			t.Errorf("reader %d SearchSources returned 0 results", i+1)
		}
		sources, listErr := reader.ListSources(ctx, SourceFilter{RepoID: "fixture-a", Kind: "doc"})
		if listErr != nil {
			t.Errorf("reader %d ListSources returned error: %v", i+1, listErr)
		}
		if len(sources) == 0 {
			t.Errorf("reader %d ListSources returned 0 sources", i+1)
		}
	}
}

func TestConcurrentCurrentSchemaOpensWhileWriterHeld(t *testing.T) {
	ctx := context.Background()
	path := filepath.Join(t.TempDir(), "cache.db")
	store, err := NewSQLiteStore(ctx, path)
	if err != nil {
		t.Fatalf("NewSQLiteStore returned error: %v", err)
	}
	defer store.Close()
	mustAddTestRepo(t, ctx, store, "fixture-a")
	mustUpsertGraph(t, ctx, store, SourceGraph{Source: testSource("DOC-OPEN", "doc", "Concurrent Open Doc")})

	lease, err := store.AcquireWriter(ctx, WriterRequest{Operation: "sync-index", RepoID: "fixture-a"})
	if err != nil {
		t.Fatalf("AcquireWriter returned error: %v", err)
	}
	defer store.ReleaseWriter(ctx, lease)

	var wg sync.WaitGroup
	errs := make(chan error, 6)
	for i := 0; i < 6; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			reader, openErr := NewSQLiteStore(ctx, path)
			if openErr != nil {
				errs <- openErr
				return
			}
			defer reader.Close()
			source, readErr := reader.GetSourceScoped(ctx, "fixture-a", "DOC-OPEN")
			if readErr != nil {
				errs <- readErr
				return
			}
			if source.ID != "DOC-OPEN" {
				errs <- fmt.Errorf("reader got unexpected source: %#v", source)
			}
		}()
	}
	wg.Wait()
	close(errs)

	for e := range errs {
		t.Errorf("concurrent current-schema open error: %v", e)
	}
}

func TestTwoWritersContentionCacheBusy(t *testing.T) {
	ctx := context.Background()
	path := filepath.Join(t.TempDir(), "cache.db")
	store, err := NewSQLiteStore(ctx, path)
	if err != nil {
		t.Fatalf("NewSQLiteStore returned error: %v", err)
	}
	defer store.Close()
	mustAddTestRepo(t, ctx, store, "fixture-a")

	lease, err := store.AcquireWriter(ctx, WriterRequest{Operation: "sync", RepoID: "fixture-a"})
	if err != nil {
		t.Fatalf("AcquireWriter returned error: %v", err)
	}
	defer store.ReleaseWriter(ctx, lease)
	if lease.Owner.CacheRef == "" || lease.Owner.CachePath != "" {
		t.Fatalf("writer owner = %#v, want opaque cache ref and no cache path", lease.Owner)
	}
	ownerJSON, err := os.ReadFile(store.lockPath)
	if err != nil {
		t.Fatalf("ReadFile(writer owner): %v", err)
	}
	if strings.Contains(string(ownerJSON), path) || strings.Contains(string(ownerJSON), "cache_path") || !strings.Contains(string(ownerJSON), "cache_ref") {
		t.Fatalf("writer owner metadata must contain cache_ref without cache path: %s", ownerJSON)
	}

	_, err = store.AcquireWriter(ctx, WriterRequest{Operation: "write", RepoID: "fixture-a"})
	if err == nil {
		t.Fatalf("second AcquireWriter succeeded, want ErrLockContention")
	}
	var contention ErrLockContention
	if !errors.As(err, &contention) {
		t.Fatalf("second AcquireWriter error = %T %[1]v, want ErrLockContention", err)
	}
	if contention.DiagnosticCode() != "cache_busy" {
		t.Fatalf("DiagnosticCode() = %q, want cache_busy", contention.DiagnosticCode())
	}
	if contention.Operation != "sync" {
		t.Fatalf("contention.Operation = %q, want sync", contention.Operation)
	}
	if contention.StartedAt.IsZero() {
		t.Fatalf("contention.StartedAt is zero, want non-zero timestamp")
	}
	if contention.PID == 0 {
		t.Fatalf("contention.PID = 0, want non-zero PID")
	}
	if contention.PublicCacheRef() == "" || strings.Contains(contention.Error(), path) {
		t.Fatalf("contention public projection leaked path or omitted cache_ref: %q", contention.Error())
	}
}

func TestThreeRoutinesTwoReadersOneWriter(t *testing.T) {
	ctx := context.Background()
	path := filepath.Join(t.TempDir(), "cache.db")
	store, err := NewSQLiteStore(ctx, path)
	if err != nil {
		t.Fatalf("NewSQLiteStore returned error: %v", err)
	}
	defer store.Close()
	mustAddTestRepo(t, ctx, store, "fixture-a")
	mustUpsertGraph(t, ctx, store, SourceGraph{Source: testSource("DOC-3G", "doc", "Three Goroutines Doc")})

	readerOne, err := NewSQLiteStore(ctx, path)
	if err != nil {
		t.Fatalf("NewSQLiteStore readerOne returned error: %v", err)
	}
	defer readerOne.Close()
	readerTwo, err := NewSQLiteStore(ctx, path)
	if err != nil {
		t.Fatalf("NewSQLiteStore readerTwo returned error: %v", err)
	}
	defer readerTwo.Close()

	lease, err := store.AcquireWriter(ctx, WriterRequest{Operation: "sync-index", RepoID: "fixture-a"})
	if err != nil {
		t.Fatalf("AcquireWriter returned error: %v", err)
	}
	defer store.ReleaseWriter(ctx, lease)

	var wg sync.WaitGroup
	readErrors := make(chan error, 2)

	wg.Add(1)
	go func(r *SQLiteStore) {
		defer wg.Done()
		source, readErr := r.GetSourceScoped(ctx, "fixture-a", "DOC-3G")
		if readErr != nil {
			readErrors <- readErr
			return
		}
		if source.RepoID != "fixture-a" || source.ID != "DOC-3G" {
			readErrors <- fmt.Errorf("reader got unexpected source: %#v", source)
		}
	}(readerOne)

	wg.Add(1)
	go func(r *SQLiteStore) {
		defer wg.Done()
		results, searchErr := r.SearchSources(ctx, SearchQuery{RepoID: "fixture-a", Query: "Goroutines", Limit: 10})
		if searchErr != nil {
			readErrors <- searchErr
			return
		}
		if len(results) == 0 {
			readErrors <- fmt.Errorf("reader SearchSources returned 0 results")
		}
	}(readerTwo)

	wg.Wait()
	close(readErrors)

	for e := range readErrors {
		t.Errorf("reader goroutine error: %v", e)
	}

	_, writeErr := readerOne.AcquireWriter(ctx, WriterRequest{Operation: "write", RepoID: "fixture-a"})
	if writeErr == nil {
		t.Fatalf("second AcquireWriter succeeded, want ErrLockContention")
	}
	var contention ErrLockContention
	if !errors.As(writeErr, &contention) {
		t.Fatalf("second AcquireWriter error = %T %[1]v, want ErrLockContention", writeErr)
	}
	if contention.DiagnosticCode() != "cache_busy" {
		t.Fatalf("DiagnosticCode() = %q, want cache_busy", contention.DiagnosticCode())
	}
}

func TestFutureSchemaIncompatibleDiagnostic(t *testing.T) {
	ctx := context.Background()
	path := filepath.Join(t.TempDir(), "future.db")
	db, err := sql.Open("sqlite", path)
	if err != nil {
		t.Fatalf("open temp db: %v", err)
	}
	setSchemaVersion(t, ctx, db, currentSchemaVersion+1)
	if err := db.Close(); err != nil {
		t.Fatalf("close temp db: %v", err)
	}

	store, err := NewSQLiteStore(ctx, path)
	if err == nil {
		store.Close()
		t.Fatalf("NewSQLiteStore returned nil error for future schema")
	}
	if !errors.Is(err, ErrSchemaVersionIncompatible) {
		t.Fatalf("NewSQLiteStore error = %v, want ErrSchemaVersionIncompatible", err)
	}

	var schemaErr *SchemaVersionError
	if !errors.As(err, &schemaErr) {
		t.Fatalf("error is not *SchemaVersionError: %T %v", err, err)
	}
	if !errors.Is(err, ErrSchemaVersionIncompatible) {
		t.Fatalf("error does not unwrap to ErrSchemaVersionIncompatible: %v", err)
	}
}