package server

import (
	"fmt"
	"net/url"
	"os"
	"strconv"
	"testing"
	"time"

	"github.com/redis/rueidis"
	rmock "github.com/redis/rueidis/mock"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/redistool"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/tlstool"
	"go.uber.org/mock/gomock"
	"google.golang.org/protobuf/types/known/timestamppb"
)

const (
	redisURLEnvName = "REDIS_URL"
)

// Test fixture that works with both mock and real Redis
type authStoreTestFixture struct {
	t         *testing.T
	ctrl      *gomock.Controller
	client    rueidis.Client
	keyPrefix string
	store     AuthStore
	isMock    bool
	cleanup   func()
}

func newAuthStoreTestFixture(t *testing.T, testType string) *authStoreTestFixture {
	// Generate a unique prefix for each test fixture to avoid weird results in cases where the test crashes
	// in a bad way that leaves us without the cleanup being executed. This only affects situations where a real Redis
	// is used.
	keyPrefix := "workspaces_test_" + generateRandomToken()
	switch testType {
	case "mock":
		ctrl := gomock.NewController(t)
		client := rmock.NewClient(ctrl)
		store := NewRedisAuthStore(client, keyPrefix, oauthStateTTL, userSessionTTL, transferTokenTTL)
		return &authStoreTestFixture{
			t:         t,
			ctrl:      ctrl,
			client:    client,
			keyPrefix: keyPrefix,
			store:     store,
			isMock:    true,
			cleanup:   func() { ctrl.Finish() },
		}
	case "real":
		client := getRedisClient(t)
		if client == nil {
			t.Skipf("%s environment variable not set, skipping test", redisURLEnvName)
		}
		store := NewRedisAuthStore(client, keyPrefix, oauthStateTTL, userSessionTTL, transferTokenTTL)
		return &authStoreTestFixture{
			t:         t,
			client:    client,
			store:     store,
			keyPrefix: keyPrefix,
			isMock:    false,
			cleanup:   func() { cleanupRealRedis(t, client, keyPrefix) },
		}
	default:
		t.Fatalf("Invalid test type: %s", testType)
		return nil
	}
}

func (f *authStoreTestFixture) expectSet(key, value string, ttl time.Duration) {
	if f.isMock {
		f.client.(*rmock.Client).EXPECT().
			Do(gomock.Any(), rmock.Match("SET", key, value, "PX", strconv.FormatInt(ttl.Milliseconds(), 10))).
			Return(rmock.Result(rmock.RedisString("OK")))
	}
}

func (f *authStoreTestFixture) expectGet(key, value string) {
	if f.isMock {
		f.client.(*rmock.Client).EXPECT().
			Do(gomock.Any(), rmock.Match("GET", key)).
			Return(rmock.Result(rmock.RedisString(value)))
	}
}

func (f *authStoreTestFixture) expectGetNotFound(key string) {
	if f.isMock {
		f.client.(*rmock.Client).EXPECT().
			Do(gomock.Any(), rmock.Match("GET", key)).
			Return(rmock.Result(rmock.RedisNil()))
	}
}

func (f *authStoreTestFixture) expectDel(key string) {
	if f.isMock {
		f.client.(*rmock.Client).EXPECT().
			Do(gomock.Any(), rmock.Match("DEL", key)).
			Return(rmock.Result(rmock.RedisInt64(1)))
	}
}

func (f *authStoreTestFixture) expectError(command, key string, err error) {
	if f.isMock {
		f.client.(*rmock.Client).EXPECT().
			Do(gomock.Any(), rmock.Match(command, key)).
			Return(rmock.Result(rmock.RedisError(err.Error())))
	}
}

func getRedisClient(t *testing.T) rueidis.Client {
	redisURL := os.Getenv(redisURLEnvName)
	if redisURL == "" {
		return nil
	}

	u, err := url.Parse(redisURL)
	require.NoError(t, err)
	opts := rueidis.ClientOption{
		ClientName:   "gitlab-agent-test",
		DisableCache: true,
	}
	switch u.Scheme {
	case "unix":
		opts.DialCtxFn = redistool.UnixDialer
		opts.InitAddress = []string{u.Path}
	case "redis":
		opts.InitAddress = []string{u.Host}
	case "rediss":
		opts.InitAddress = []string{u.Host}
		opts.TLSConfig = tlstool.ClientConfig()
	default:
		opts.InitAddress = []string{redisURL}
	}
	client, err := rueidis.NewClient(opts)
	require.NoError(t, err)
	return client
}

func cleanupRealRedis(t *testing.T, client rueidis.Client, keyPrefix string) {
	ctx := t.Context()
	pattern := keyPrefix + ":*"

	var cursor uint64
	for {
		cmd := client.B().Scan().Cursor(cursor).Match(pattern).Count(100).Build()
		result := client.Do(ctx, cmd)
		if result.Error() != nil {
			break
		}

		scanResult, err := result.ToArray()
		if err != nil || len(scanResult) < 2 {
			break
		}

		keys, _ := scanResult[1].AsStrSlice()
		if len(keys) > 0 {
			delCmd := client.B().Del().Key(keys...).Build()
			client.Do(ctx, delCmd)
		}

		cursor, _ = scanResult[0].AsUint64()
		if cursor == 0 {
			break
		}
	}
}

// Tests for OAuth state operations

func TestRedisAuthStore_OAuthState_RoundTrip(t *testing.T) {
	testCases := []string{"mock", "real"}

	for _, testType := range testCases {
		t.Run(testType, func(t *testing.T) {
			fixture := newAuthStoreTestFixture(t, testType)
			defer fixture.cleanup()

			ctx := t.Context()
			stateValue := "oauth-state-round-trip"
			oauthState := &OAuthState{
				CreatedAt:    timestamppb.Now(),
				OriginalUrl:  "https://example.com",
				CodeVerifier: "abc",
				StateValue:   stateValue,
			}

			key := fmt.Sprintf("%s:oauth_states:%s", fixture.keyPrefix, stateValue)
			oauthStateStr := string(mustMarshalProto(t, oauthState))

			// Set up expectations
			fixture.expectGetNotFound(key)
			fixture.expectSet(key, oauthStateStr, oauthStateTTL)
			fixture.expectGet(key, oauthStateStr)
			fixture.expectDel(key)

			// Get - Not found
			retrieved, err := fixture.store.GetOAuthState(ctx, stateValue)
			require.NoError(t, err)
			require.Nil(t, retrieved)

			// Store
			err = fixture.store.StoreOAuthState(ctx, stateValue, oauthState)
			require.NoError(t, err)

			// Get
			retrieved, err = fixture.store.GetOAuthState(ctx, stateValue)
			require.NoError(t, err)
			require.NotNil(t, retrieved)
			assert.Equal(t, oauthState.String(), retrieved.String())

			// Delete
			err = fixture.store.DeleteOAuthState(ctx, stateValue)
			require.NoError(t, err)

			// Verify deleted (only with real Redis)
			if !fixture.isMock {
				deleted, err := fixture.store.GetOAuthState(ctx, stateValue)
				require.NoError(t, err)
				assert.Nil(t, deleted)
			}
		})
	}
}

// Error tests (mock only)
func TestRedisAuthStore_GetOAuthState_RedisError(t *testing.T) {
	fixture := newAuthStoreTestFixture(t, "mock")
	defer fixture.cleanup()

	stateValue := "abc"
	key := fmt.Sprintf("%s:oauth_states:%s", fixture.keyPrefix, stateValue)

	fixture.expectError("GET", key, fmt.Errorf("redis failed"))

	retrieved, err := fixture.store.GetOAuthState(t.Context(), stateValue)
	require.Error(t, err)
	assert.ErrorContains(t, err, "redis failed")
	assert.Nil(t, retrieved)
}

// Tests for Transfer token operations

func TestRedisAuthStore_TransferToken_RoundTrip(t *testing.T) {
	testCases := []string{"mock", "real"}

	for _, testType := range testCases {
		t.Run(testType, func(t *testing.T) {
			fixture := newAuthStoreTestFixture(t, testType)
			defer fixture.cleanup()

			ctx := t.Context()
			token := "transfer-token-round-trip"
			transferToken := &TransferToken{
				CreatedAt:   timestamppb.Now(),
				OriginalUrl: "https://example.com",
				User:        &User{Id: 123},
				Workspace:   &Workspace{Id: 456, Port: 789},
			}

			key := fmt.Sprintf("%s:transfer_tokens:%s", fixture.keyPrefix, token)
			ttStr := string(mustMarshalProto(t, transferToken))

			// Set up expectations
			fixture.expectGetNotFound(key)
			fixture.expectSet(key, ttStr, transferTokenTTL)
			fixture.expectGet(key, ttStr)
			fixture.expectDel(key)

			// Get - Not found
			retrieved, err := fixture.store.GetTransferToken(ctx, token)
			require.NoError(t, err)
			require.Nil(t, retrieved)

			// Store
			err = fixture.store.StoreTransferToken(ctx, token, transferToken)
			require.NoError(t, err)

			// Get
			retrieved, err = fixture.store.GetTransferToken(ctx, token)
			require.NoError(t, err)
			require.NotNil(t, retrieved)
			assert.Equal(t, transferToken.String(), retrieved.String())

			// Delete
			err = fixture.store.DeleteTransferToken(ctx, token)
			require.NoError(t, err)

			// Verify deleted (only with real Redis)
			if !fixture.isMock {
				deleted, err := fixture.store.GetTransferToken(ctx, token)
				require.NoError(t, err)
				assert.Nil(t, deleted)
			}
		})
	}
}

// Error tests (mock only)
func TestRedisAuthStore_GetTransferToken_RedisError(t *testing.T) {
	fixture := newAuthStoreTestFixture(t, "mock")
	defer fixture.cleanup()

	token := "abc"
	key := fmt.Sprintf("%s:transfer_tokens:%s", fixture.keyPrefix, token)

	fixture.expectError("GET", key, fmt.Errorf("redis failed"))

	retrieved, err := fixture.store.GetTransferToken(t.Context(), token)
	require.Error(t, err)
	assert.ErrorContains(t, err, "redis failed")
	assert.Nil(t, retrieved)
}

// Tests for User session operations

func TestRedisAuthStore_UserSession_RoundTrip(t *testing.T) {
	testCases := []string{"mock", "real"}

	for _, testType := range testCases {
		t.Run(testType, func(t *testing.T) {
			fixture := newAuthStoreTestFixture(t, testType)
			defer fixture.cleanup()

			ctx := t.Context()
			sessionID := "user-session-round-trip"
			userSession := &UserSession{
				CreatedAt: timestamppb.Now(),
				Host:      "example.com",
				User:      &User{Id: 123},
				Workspace: &Workspace{Id: 456, Port: 789},
			}

			key := fmt.Sprintf("%s:user_sessions:%s", fixture.keyPrefix, sessionID)
			usStr := string(mustMarshalProto(t, userSession))

			// Set up expectations
			fixture.expectGetNotFound(key)
			fixture.expectSet(key, usStr, userSessionTTL)
			fixture.expectGet(key, usStr)
			fixture.expectDel(key)

			// Get - Not found
			retrieved, err := fixture.store.GetUserSession(ctx, sessionID)
			require.NoError(t, err)
			require.Nil(t, retrieved)

			// Store
			err = fixture.store.StoreUserSession(ctx, sessionID, userSession)
			require.NoError(t, err)

			// Get
			retrieved, err = fixture.store.GetUserSession(ctx, sessionID)
			require.NoError(t, err)
			require.NotNil(t, retrieved)
			assert.Equal(t, userSession.String(), retrieved.String())

			// Delete
			err = fixture.store.DeleteUserSession(ctx, sessionID)
			require.NoError(t, err)

			// Verify deleted (only with real Redis)
			if !fixture.isMock {
				deleted, err := fixture.store.GetUserSession(ctx, sessionID)
				require.NoError(t, err)
				assert.Nil(t, deleted)
			}
		})
	}
}

// Error tests (mock only)
func TestRedisAuthStore_GetUserSession_RedisError(t *testing.T) {
	fixture := newAuthStoreTestFixture(t, "mock")
	defer fixture.cleanup()

	sessionID := "abc"
	key := fmt.Sprintf("%s:user_sessions:%s", fixture.keyPrefix, sessionID)

	fixture.expectError("GET", key, fmt.Errorf("redis failed"))

	retrieved, err := fixture.store.GetUserSession(t.Context(), sessionID)
	require.Error(t, err)
	assert.ErrorContains(t, err, "redis failed")
	assert.Nil(t, retrieved)
}
