//go:build integration
// +build integration

package refreshtokensdb_test

import (
	"context"
	"errors"
	"fmt"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/tech/sendico/pkg/db/internal/mongo/refreshtokensdb"
	"github.com/tech/sendico/pkg/db/repository"
	"github.com/tech/sendico/pkg/db/repository/builder"
	"github.com/tech/sendico/pkg/merrors"
	factory "github.com/tech/sendico/pkg/mlogger/factory"
	"github.com/tech/sendico/pkg/model"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/modules/mongodb"
	"github.com/testcontainers/testcontainers-go/wait"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) {
	db, _, cleanup := setupTestDBWithMongo(t)
	return db, cleanup
}

func setupTestDBWithMongo(t *testing.T) (*refreshtokensdb.RefreshTokenDB, *mongo.Database, func()) {
	// mark as helper for better test failure reporting
	t.Helper()

	startCtx, startCancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer startCancel()

	mongoContainer, err := mongodb.Run(startCtx,
		"mongo:latest",
		mongodb.WithUsername("root"),
		mongodb.WithPassword("password"),
		testcontainers.WithWaitStrategy(wait.ForListeningPort("27017/tcp").WithStartupTimeout(2*time.Minute)),
	)
	require.NoError(t, err, "failed to start MongoDB container")

	mongoURI, err := mongoContainer.ConnectionString(startCtx)
	require.NoError(t, err, "failed to get MongoDB connection string")

	clientOptions := options.Client().ApplyURI(mongoURI)
	client, err := mongo.Connect(startCtx, clientOptions)
	require.NoError(t, err, "failed to connect to MongoDB")

	database := client.Database("test_refresh_tokens_" + t.Name())
	logger := factory.NewLogger(true)

	db, err := refreshtokensdb.Create(logger, database)
	require.NoError(t, err, "failed to create refresh tokens db")

	cleanup := func() {
		_ = database.Drop(context.Background())
		_ = client.Disconnect(context.Background())
		termCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()
		_ = mongoContainer.Terminate(termCtx)
	}

	return db, database, cleanup
}

func createTestRefreshToken(accountRef bson.ObjectID, clientID, deviceID, token string) *model.RefreshToken {
	return &model.RefreshToken{
		ClientRefreshToken: model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: deviceID,
			},
			RefreshToken: token,
		},
		AccountBoundBase: model.AccountBoundBase{
			AccountRef: &accountRef,
		},
		ExpiresAt:  time.Now().Add(24 * time.Hour),
		IsRevoked:  false,
		UserAgent:  "TestUserAgent/1.0",
		IPAddress:  "192.168.1.1",
		LastUsedAt: time.Now(),
	}
}

func TestRefreshTokenDB_AuthenticationFlow(t *testing.T) {
	db, cleanup := setupTestDB(t)
	defer cleanup()

	ctx := context.Background()

	t.Run("Complete_User_Authentication_Flow", func(t *testing.T) {
		// Setup: Create user and client
		userID := bson.NewObjectID()
		clientID := "web-app"
		deviceID := "user-desktop-chrome"
		token := "refresh_token_12345"

		// Step 1: User logs in - create initial refresh token
		refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
		err := db.Create(ctx, refreshToken)
		require.NoError(t, err)

		// Step 2: User uses refresh token to get new access token
		crt := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: deviceID,
			},
			RefreshToken: token,
		}

		retrievedToken, err := db.GetByCRT(ctx, crt)
		require.NoError(t, err)
		require.NotNil(t, retrievedToken.AccountRef)
		assert.Equal(t, userID, *retrievedToken.AccountRef)
		assert.Equal(t, token, retrievedToken.RefreshToken)
		assert.False(t, retrievedToken.IsRevoked)

		// Step 3: User logs out - revoke the token
		session := &model.SessionIdentifier{
			ClientID: clientID,
			DeviceID: deviceID,
		}
		err = db.Revoke(ctx, userID, session)
		require.NoError(t, err)

		// Step 4: Try to use revoked token - should fail
		_, err = db.GetByCRT(ctx, crt)
		assert.Error(t, err)
		assert.True(t, errors.Is(err, merrors.ErrNoData))
	})

	t.Run("Manual_Token_Revocation_Workaround", func(t *testing.T) {
		// Test manual revocation by directly updating the token
		userID := bson.NewObjectID()
		clientID := "web-app"
		deviceID := "user-desktop-chrome"
		token := "manual_revoke_token_123"

		// Step 1: Create token
		refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
		err := db.Create(ctx, refreshToken)
		require.NoError(t, err)

		// Step 2: Manually revoke token by updating it directly
		refreshToken.IsRevoked = true
		err = db.Update(ctx, refreshToken)
		require.NoError(t, err)

		// Step 3: Try to use revoked token - should fail
		crt := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: deviceID,
			},
			RefreshToken: token,
		}

		_, err = db.GetByCRT(ctx, crt)
		assert.Error(t, err)
		assert.True(t, errors.Is(err, merrors.ErrNoData))
	})
}

func TestRefreshTokenDB_MultiDeviceManagement(t *testing.T) {
	db, cleanup := setupTestDB(t)
	defer cleanup()

	ctx := context.Background()

	t.Run("User_With_Multiple_Devices", func(t *testing.T) {
		userID := bson.NewObjectID()
		clientID := "mobile-app"

		// User logs in from phone
		phoneToken := createTestRefreshToken(userID, clientID, "phone-ios", "phone_token_123")
		err := db.Create(ctx, phoneToken)
		require.NoError(t, err)

		// User logs in from tablet
		tabletToken := createTestRefreshToken(userID, clientID, "tablet-android", "tablet_token_456")
		err = db.Create(ctx, tabletToken)
		require.NoError(t, err)

		// User logs in from desktop
		desktopToken := createTestRefreshToken(userID, clientID, "desktop-windows", "desktop_token_789")
		err = db.Create(ctx, desktopToken)
		require.NoError(t, err)

		// User wants to logout from all devices except current (phone)
		err = db.RevokeAll(ctx, userID, "phone-ios")
		require.NoError(t, err)

		// Phone should still work
		phoneCRT := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: "phone-ios",
			},
			RefreshToken: "phone_token_123",
		}
		_, err = db.GetByCRT(ctx, phoneCRT)
		require.NoError(t, err)

		// Tablet and desktop should be revoked
		tabletCRT := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: "tablet-android",
			},
			RefreshToken: "tablet_token_456",
		}
		_, err = db.GetByCRT(ctx, tabletCRT)
		assert.Error(t, err)

		desktopCRT := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: "desktop-windows",
			},
			RefreshToken: "desktop_token_789",
		}
		_, err = db.GetByCRT(ctx, desktopCRT)
		assert.Error(t, err)
	})
}

func TestRefreshTokenDB_TokenRotation(t *testing.T) {
	db, cleanup := setupTestDB(t)
	defer cleanup()

	ctx := context.Background()

	t.Run("Token_Rotation_On_Use", func(t *testing.T) {
		userID := bson.NewObjectID()
		clientID := "web-app"
		deviceID := "user-browser"
		initialToken := "initial_token_123"

		// Create initial token
		refreshToken := createTestRefreshToken(userID, clientID, deviceID, initialToken)
		err := db.Create(ctx, refreshToken)
		require.NoError(t, err)

		// Simulate small delay
		time.Sleep(10 * time.Millisecond)

		// Use token - should update LastUsedAt
		crt := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: deviceID,
			},
			RefreshToken: initialToken,
		}

		retrievedToken, err := db.GetByCRT(ctx, crt)
		require.NoError(t, err)
		// LastUsedAt is not updated by GetByCRT; validate token data instead
		assert.Equal(t, initialToken, retrievedToken.RefreshToken)

		// Create new token with rotated value (simulating token rotation)
		newToken := "rotated_token_456"
		retrievedToken.RefreshToken = newToken
		err = db.Update(ctx, retrievedToken)
		require.NoError(t, err)

		// Old token should no longer work
		_, err = db.GetByCRT(ctx, crt)
		assert.Error(t, err)

		// New token should work
		newCRT := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: deviceID,
			},
			RefreshToken: newToken,
		}
		_, err = db.GetByCRT(ctx, newCRT)
		require.NoError(t, err)
	})
}

func TestRefreshTokenDB_SessionReplacement(t *testing.T) {
	db, cleanup := setupTestDB(t)
	defer cleanup()

	ctx := context.Background()

	t.Run("User_Login_From_Same_Device_Twice", func(t *testing.T) {
		userID := bson.NewObjectID()
		clientID := "web-app"
		deviceID := "user-laptop"

		// First login
		firstToken := createTestRefreshToken(userID, clientID, deviceID, "first_token_123")
		err := db.Create(ctx, firstToken)
		require.NoError(t, err)
		firstTokenID := *firstToken.GetID()

		// Second login from same device - should replace existing token
		secondToken := createTestRefreshToken(userID, clientID, deviceID, "second_token_456")
		err = db.Create(ctx, secondToken)
		require.NoError(t, err)

		// Should reuse the same database record
		assert.Equal(t, firstTokenID, *secondToken.GetID())

		// First token should no longer work
		firstCRT := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: deviceID,
			},
			RefreshToken: "first_token_123",
		}
		_, err = db.GetByCRT(ctx, firstCRT)
		assert.Error(t, err)

		// Second token should work
		secondCRT := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: deviceID,
			},
			RefreshToken: "second_token_456",
		}
		_, err = db.GetByCRT(ctx, secondCRT)
		require.NoError(t, err)
	})

	t.Run("Create_After_GlobalRevocation_AllowsNewActive", func(t *testing.T) {
		userID := bson.NewObjectID()
		clientID := "web-app"
		deviceID := "user-laptop"

		firstToken := createTestRefreshToken(userID, clientID, deviceID, "revoked_token_123")
		err := db.Create(ctx, firstToken)
		require.NoError(t, err)
		require.NotNil(t, firstToken.GetID())

		// Global revoke (deviceID empty) — all tokens should be revoked
		err = db.RevokeAll(ctx, userID, "")
		require.NoError(t, err)

		var revoked model.RefreshToken
		err = db.Get(ctx, *firstToken.GetID(), &revoked)
		require.NoError(t, err)
		assert.True(t, revoked.IsRevoked)

		// Creating a new token for the same account/client/device must succeed and produce an active token
		reissueToken := createTestRefreshToken(userID, clientID, deviceID, "new_token_after_revocation")
		err = db.Create(ctx, reissueToken)
		require.NoError(t, err)

		newCRT := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: deviceID,
			},
			RefreshToken: "new_token_after_revocation",
		}
		_, err = db.GetByCRT(ctx, newCRT)
		require.NoError(t, err)

		// Old token must remain unusable
		oldCRT := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: deviceID,
			},
			RefreshToken: "revoked_token_123",
		}
		_, err = db.GetByCRT(ctx, oldCRT)
		assert.Error(t, err)

		// Both records exist: revoked + new active
		query := repository.Query().
			Filter(repository.AccountField(), userID).
			And(
				repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID),
				repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, deviceID),
			)
		ids, err := db.Repository.ListIDs(ctx, query)
		require.NoError(t, err)
		assert.Len(t, ids, 2)
	})
}

func TestRefreshTokenDB_ClientManagement(t *testing.T) {
	db, cleanup := setupTestDB(t)
	defer cleanup()

	ctx := context.Background()

	t.Run("Client_CRUD_Operations", func(t *testing.T) {
		// Note: Client management is handled by a separate client database
		// This test verifies that refresh tokens work with different client IDs

		userID := bson.NewObjectID()

		// Create refresh tokens for different clients
		webToken := createTestRefreshToken(userID, "web-app", "device1", "token1")
		err := db.Create(ctx, webToken)
		require.NoError(t, err)

		mobileToken := createTestRefreshToken(userID, "mobile-app", "device2", "token2")
		err = db.Create(ctx, mobileToken)
		require.NoError(t, err)

		// Verify tokens can be retrieved by client ID
		webCRT := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: "web-app",
				DeviceID: "device1",
			},
			RefreshToken: "token1",
		}

		retrievedToken, err := db.GetByCRT(ctx, webCRT)
		require.NoError(t, err)
		assert.Equal(t, "web-app", retrievedToken.ClientID)
		assert.Equal(t, "device1", retrievedToken.DeviceID)

		mobileCRT := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: "mobile-app",
				DeviceID: "device2",
			},
			RefreshToken: "token2",
		}

		retrievedToken, err = db.GetByCRT(ctx, mobileCRT)
		require.NoError(t, err)
		assert.Equal(t, "mobile-app", retrievedToken.ClientID)
		assert.Equal(t, "device2", retrievedToken.DeviceID)
	})
}

func TestRefreshTokenDB_SecurityScenarios(t *testing.T) {
	db, cleanup := setupTestDB(t)
	defer cleanup()

	ctx := context.Background()

	t.Run("Token_Hijacking_Prevention", func(t *testing.T) {
		userID := bson.NewObjectID()
		clientID := "web-app"
		deviceID := "user-browser"
		token := "hijacked_token_123"

		// Create legitimate token
		refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
		err := db.Create(ctx, refreshToken)
		require.NoError(t, err)

		// Simulate security concern - revoke token
		session := &model.SessionIdentifier{
			ClientID: clientID,
			DeviceID: deviceID,
		}
		err = db.Revoke(ctx, userID, session)
		require.NoError(t, err)

		// Attacker tries to use hijacked token
		crt := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: deviceID,
			},
			RefreshToken: token,
		}

		_, err = db.GetByCRT(ctx, crt)
		assert.Error(t, err)
		assert.True(t, errors.Is(err, merrors.ErrNoData))
	})

	t.Run("Invalid_Token_Attempts", func(t *testing.T) {
		// Try to use completely invalid token
		crt := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: "invalid-client",
				DeviceID: "invalid-device",
			},
			RefreshToken: "invalid_token_123",
		}

		_, err := db.GetByCRT(ctx, crt)
		assert.Error(t, err)
		assert.True(t, errors.Is(err, merrors.ErrNoData))
	})
}

func TestRefreshTokenDB_ExpiredTokenHandling(t *testing.T) {
	db, cleanup := setupTestDB(t)
	defer cleanup()

	ctx := context.Background()

	t.Run("Expired_Token_Cleanup", func(t *testing.T) {
		userID := bson.NewObjectID()
		clientID := "web-app"
		deviceID := "user-device"
		token := "expired_token_123"

		// Create token that expires in the past
		refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
		refreshToken.ExpiresAt = time.Now().Add(-1 * time.Hour) // Expired 1 hour ago
		err := db.Create(ctx, refreshToken)
		require.NoError(t, err)

		// The token exists in database but is expired
		var storedToken model.RefreshToken
		err = db.Get(ctx, *refreshToken.GetID(), &storedToken)
		require.NoError(t, err)
		assert.True(t, storedToken.ExpiresAt.Before(time.Now()))

		// Application should reject expired tokens
		crt := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: deviceID,
			},
			RefreshToken: token,
		}

		_, err = db.GetByCRT(ctx, crt)
		assert.Error(t, err)
		assert.True(t, errors.Is(err, merrors.ErrAccessDenied))
	})
}

func TestRefreshTokenDB_ConcurrentAccess(t *testing.T) {
	db, cleanup := setupTestDB(t)
	defer cleanup()

	ctx := context.Background()

	t.Run("Concurrent_Token_Usage", func(t *testing.T) {
		userID := bson.NewObjectID()
		clientID := "web-app"
		deviceID := "user-device"
		token := "concurrent_token_123"

		// Create token
		refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
		err := db.Create(ctx, refreshToken)
		require.NoError(t, err)

		crt := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: deviceID,
			},
			RefreshToken: token,
		}

		// Simulate concurrent access
		done := make(chan error, 2)

		go func() {
			_, err := db.GetByCRT(ctx, crt)
			done <- err
		}()

		go func() {
			_, err := db.GetByCRT(ctx, crt)
			done <- err
		}()

		// Both operations should succeed
		for i := 0; i < 2; i++ {
			err := <-done
			require.NoError(t, err)
		}
	})
}

func TestRefreshTokenDB_EdgeCases(t *testing.T) {
	db, cleanup := setupTestDB(t)
	defer cleanup()

	ctx := context.Background()

	t.Run("Delete_Token_By_ID", func(t *testing.T) {
		userID := bson.NewObjectID()
		refreshToken := createTestRefreshToken(userID, "web-app", "device-1", "token_123")
		err := db.Create(ctx, refreshToken)
		require.NoError(t, err)

		tokenID := *refreshToken.GetID()

		// Delete token
		err = db.Delete(ctx, tokenID)
		require.NoError(t, err)

		// Token should no longer exist
		var result model.RefreshToken
		err = db.Get(ctx, tokenID, &result)
		assert.Error(t, err)
		assert.True(t, errors.Is(err, merrors.ErrNoData))
	})

	t.Run("Revoke_Non_Existent_Token", func(t *testing.T) {
		userID := bson.NewObjectID()
		session := &model.SessionIdentifier{
			ClientID: "non-existent-client",
			DeviceID: "non-existent-device",
		}

		err := db.Revoke(ctx, userID, session)
		// Should handle gracefully for non-existent tokens
		assert.NoError(t, err)
	})

	t.Run("RevokeAll_No_Other_Devices", func(t *testing.T) {
		userID := bson.NewObjectID()
		clientID := "web-app"
		deviceID := "only-device"

		// Create single token
		refreshToken := createTestRefreshToken(userID, clientID, deviceID, "token_123")
		err := db.Create(ctx, refreshToken)
		require.NoError(t, err)

		// RevokeAll should not affect current device
		err = db.RevokeAll(ctx, userID, deviceID)
		require.NoError(t, err)

		// Token should still work
		crt := &model.ClientRefreshToken{
			SessionIdentifier: model.SessionIdentifier{
				ClientID: clientID,
				DeviceID: deviceID,
			},
			RefreshToken: "token_123",
		}

		_, err = db.GetByCRT(ctx, crt)
		require.NoError(t, err)
	})
}

func TestRefreshTokenDB_DatabaseIndexes(t *testing.T) {
	db, cleanup := setupTestDB(t)
	defer cleanup()

	ctx := context.Background()

	t.Run("Unique_Token_Constraint", func(t *testing.T) {
		userID1 := bson.NewObjectID()
		userID2 := bson.NewObjectID()
		token := "duplicate_token_123"

		// Create first token
		refreshToken1 := createTestRefreshToken(userID1, "client1", "device1", token)
		err := db.Create(ctx, refreshToken1)
		require.NoError(t, err)

		// Try to create second token with same token value - should fail due to unique index
		refreshToken2 := createTestRefreshToken(userID2, "client2", "device2", token)
		err = db.Create(ctx, refreshToken2)
		assert.Error(t, err)
		assert.Contains(t, err.Error(), "duplicate")
	})

	t.Run("Query_Performance_By_Revocation_Status", func(t *testing.T) {
		userID := bson.NewObjectID()
		clientID := "web-app"

		// Create multiple tokens
		for i := 0; i < 10; i++ {
			token := createTestRefreshToken(userID, clientID,
				fmt.Sprintf("device_%d", i), fmt.Sprintf("token_%d", i))
			if i%2 == 0 {
				token.IsRevoked = true
			}
			err := db.Create(ctx, token)
			require.NoError(t, err)
		}

		// Query should efficiently filter by revocation status
		query := repository.Query().
			Filter(repository.AccountField(), userID).
			And(repository.Query().Comparison(repository.Field(refreshtokensdb.IsRevokedField), builder.Eq, false))

		ids, err := db.ListIDs(ctx, query)
		require.NoError(t, err)
		assert.Len(t, ids, 5) // Should find 5 non-revoked tokens
	})
}

func TestRefreshTokenDB_IndexPartialUniqueActiveSession(t *testing.T) {
	db, database, cleanup := setupTestDBWithMongo(t)
	defer cleanup()

	ctx := context.Background()

	cursor, err := database.Collection(db.Repository.Collection()).Indexes().List(ctx)
	require.NoError(t, err)
	defer cursor.Close(ctx)

	found := false
	for cursor.Next(ctx) {
		var idx bson.M
		require.NoError(t, cursor.Decode(&idx))
		if idx["name"] == "unique_active_session" {
			found = true
			assert.Equal(t, true, idx["unique"])

			partial, ok := idx["partialFilterExpression"].(bson.M)
			require.True(t, ok)
			assert.Equal(t, bson.M{"isRevoked": false}, partial)
		}
	}
	assert.True(t, found, "unique_active_session index not found")
}
