// Copyright (C) MongoDB, Inc. 2017-present.
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at http://www.apache.org/licenses/LICENSE-2.0

package integration

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"reflect"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/event"
	"go.mongodb.org/mongo-driver/v2/internal/assert"
	"go.mongodb.org/mongo-driver/v2/internal/integration/mtest"
	"go.mongodb.org/mongo-driver/v2/internal/mongoutil"
	"go.mongodb.org/mongo-driver/v2/internal/require"
	"go.mongodb.org/mongo-driver/v2/internal/testutil"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
	"go.mongodb.org/mongo-driver/v2/mongo/readpref"
	"go.mongodb.org/mongo-driver/v2/mongo/writeconcern"
	"go.mongodb.org/mongo-driver/v2/x/mongo/driver/session"
	"golang.org/x/sync/errgroup"
)

func TestSessionPool(t *testing.T) {
	mt := mtest.New(t, mtest.NewOptions().MinServerVersion("3.6").CreateClient(false))

	mt.Run("last use time updated", func(mt *mtest.T) {
		sess, err := mt.Client.StartSession()
		assert.Nil(mt, err, "StartSession error: %v", err)
		defer sess.EndSession(context.Background())
		initialLastUsedTime := testutil.GetUnexportedFieldAs[*session.Client](sess, "clientSession").LastUsed

		err = mongo.WithSession(context.Background(), sess, func(sc context.Context) error {
			return mt.Client.Ping(sc, readpref.Primary())
		})
		assert.Nil(mt, err, "WithSession error: %v", err)

		newLastUsedTime := testutil.GetUnexportedFieldAs[*session.Client](sess, "clientSession").LastUsed
		assert.True(mt, newLastUsedTime.After(initialLastUsedTime),
			"last used time %s is not after the initial last used time %s", newLastUsedTime, initialLastUsedTime)
	})
}

func TestSessions(t *testing.T) {
	mtOpts := mtest.NewOptions().MinServerVersion("3.6").Topologies(mtest.ReplicaSet, mtest.Sharded).
		CreateClient(false)
	mt := mtest.New(t, mtOpts)

	mt.Run("imperative API", func(mt *mtest.T) {
		mt.Run("round trip Session object", func(mt *mtest.T) {
			// Roundtrip a Session object through NewSessionContext/ContextFromSession and assert that it is correctly
			// stored/retrieved.

			sess, err := mt.Client.StartSession()
			assert.Nil(mt, err, "StartSession error: %v", err)
			defer sess.EndSession(context.Background())

			ctx := mongo.NewSessionContext(context.Background(), sess)

			gotSess := mongo.SessionFromContext(ctx)
			assert.NotNil(mt, gotSess, "expected SessionFromContext to return non-nil value, got nil")
			assert.Equal(mt, sess.ID(), gotSess.ID(), "expected Session ID %v, got %v", sess.ID(), gotSess.ID())
		})

		txnOpts := mtest.NewOptions().RunOn(
			mtest.RunOnBlock{Topology: []mtest.TopologyKind{mtest.ReplicaSet}, MinServerVersion: "4.0"},
			mtest.RunOnBlock{Topology: []mtest.TopologyKind{mtest.Sharded}, MinServerVersion: "4.2"},
		)
		mt.RunOpts("run transaction", txnOpts, func(mt *mtest.T) {
			// Test that the imperative sessions API can be used to run a transaction.

			createSessionContext := func(mt *mtest.T) context.Context {
				sess, err := mt.Client.StartSession()
				assert.Nil(mt, err, "StartSession error: %v", err)

				return mongo.NewSessionContext(context.Background(), sess)
			}

			ctx := createSessionContext(mt)
			sess := mongo.SessionFromContext(ctx)
			assert.NotNil(mt, sess, "expected SessionFromContext to return non-nil value, got nil")
			defer sess.EndSession(context.Background())

			err := sess.StartTransaction()
			assert.Nil(mt, err, "StartTransaction error: %v", err)

			numDocs := 2
			for i := 0; i < numDocs; i++ {
				_, err = mt.Coll.InsertOne(ctx, bson.D{{"x", 1}})
				assert.Nil(mt, err, "InsertOne error at index %d: %v", i, err)
			}

			// Assert that the collection count is 0 before committing and numDocs after. This tests that the InsertOne
			// calls were actually executed in the transaction because the pre-commit count does not include them.
			assertCollectionCount(mt, 0)
			err = sess.CommitTransaction(ctx)
			assert.Nil(mt, err, "CommitTransaction error: %v", err)
			assertCollectionCount(mt, int64(numDocs))
		})
	})

	unackWcOpts := options.Collection().SetWriteConcern(writeconcern.Unacknowledged())
	mt.RunOpts("unacknowledged write", mtest.NewOptions().CollectionOptions(unackWcOpts), func(mt *mtest.T) {
		// unacknowledged write during a session should result in an error
		sess, err := mt.Client.StartSession()
		assert.Nil(mt, err, "StartSession error: %v", err)
		defer sess.EndSession(context.Background())

		var res *mongo.InsertOneResult

		err = mongo.WithSession(context.Background(), sess, func(sc context.Context) error {
			res, err = mt.Coll.InsertOne(sc, bson.D{{"x", 1}})

			return err
		})

		assert.NoError(mt, err)
		assert.False(mt, res.Acknowledged)
	})

	// Regression test for GODRIVER-2533. Note that this test assumes the race
	// detector is enabled (GODRIVER-2072).
	mt.Run("NumberSessionsInProgress data race", func(mt *mtest.T) {
		// Use two goroutines to execute a few simultaneous runs of NumberSessionsInProgress
		// and a basic collection operation (CountDocuments).
		var wg sync.WaitGroup
		wg.Add(2)

		go func() {
			defer wg.Done()

			for i := 0; i < 100; i++ {
				time.Sleep(100 * time.Microsecond)
				_ = mt.Client.NumberSessionsInProgress()
			}
		}()
		go func() {
			defer wg.Done()

			for i := 0; i < 100; i++ {
				time.Sleep(100 * time.Microsecond)
				_, err := mt.Coll.CountDocuments(context.Background(), bson.D{})
				assert.Nil(mt, err, "CountDocument error: %v", err)
			}
		}()

		wg.Wait()
	})
}

func TestSessionsProse(t *testing.T) {
	mtOpts := mtest.
		NewOptions().
		MinServerVersion("3.6").
		Topologies(mtest.ReplicaSet, mtest.Sharded).
		CreateClient(false)

	mt := mtest.New(t, mtOpts)

	hosts, err := mongoutil.HostsFromURI(mtest.ClusterURI())
	require.NoError(t, err)

	mt.Run("1 setting both snapshot and causalConsistency to true is not allowed", func(mt *mtest.T) {
		// causalConsistency and snapshot are mutually exclusive
		sessOpts := options.Session().SetCausalConsistency(true).SetSnapshot(true)
		_, err := mt.Client.StartSession(sessOpts)
		assert.NotNil(mt, err, "expected StartSession error, got nil")
		expectedErr := errors.New("causal consistency and snapshot cannot both be set for a session")
		assert.Equal(mt, expectedErr, err, "expected error %v, got %v", expectedErr, err)
	})

	mt.Run("2 pool is LIFO", func(mt *mtest.T) {
		aSess, err := mt.Client.StartSession()
		assert.Nil(mt, err, "StartSession error: %v", err)
		bSess, err := mt.Client.StartSession()
		assert.Nil(mt, err, "StartSession error: %v", err)

		// end the sessions to return them to the pool
		aSess.EndSession(context.Background())
		bSess.EndSession(context.Background())

		firstSess, err := mt.Client.StartSession()
		assert.Nil(mt, err, "StartSession error: %v", err)
		defer firstSess.EndSession(context.Background())
		want := bSess.ID()
		got := firstSess.ID()
		assert.True(mt, sessionIDsEqual(mt, want, got), "expected session ID %v, got %v", want, got)

		secondSess, err := mt.Client.StartSession()
		assert.Nil(mt, err, "StartSession error: %v", err)
		defer secondSess.EndSession(context.Background())
		want = aSess.ID()
		got = secondSess.ID()
		assert.True(mt, sessionIDsEqual(mt, want, got), "expected session ID %v, got %v", want, got)
	})

	// Pin to a single mongos so heartbeats/handshakes to other mongoses
	// won't cause errors.
	clusterTimeOpts := mtest.NewOptions().
		ClientOptions(options.Client().SetHeartbeatInterval(50 * time.Second)).
		ClientType(mtest.Pinned).
		CreateClient(false)

	mt.RunOpts("3 clusterTime in commands", clusterTimeOpts, func(mt *mtest.T) {
		serverStatus := sessionFunction{"server status", "database", "RunCommand", []any{bson.D{{"serverStatus", 1}}}}
		insert := sessionFunction{"insert one", "collection", "InsertOne", []any{bson.D{{"x", 1}}}}
		agg := sessionFunction{"aggregate", "collection", "Aggregate", []any{mongo.Pipeline{}}}
		find := sessionFunction{"find", "collection", "Find", []any{bson.D{}}}

		sessionFunctions := []sessionFunction{serverStatus, insert, agg, find}
		for _, sf := range sessionFunctions {
			mt.Run(sf.name, func(mt *mtest.T) {
				err := sf.execute(mt, nil)
				assert.Nil(mt, err, "%v error: %v", sf.name, err)

				// assert $clusterTime was sent to server
				started := mt.GetStartedEvent()
				assert.NotNil(mt, started, "expected started event, got nil")
				_, err = started.Command.LookupErr("$clusterTime")
				assert.Nil(mt, err, "$clusterTime not sent")

				// record response cluster time
				succeeded := mt.GetSucceededEvent()
				assert.NotNil(mt, succeeded, "expected succeeded event, got nil")
				replyClusterTimeVal, err := succeeded.Reply.LookupErr("$clusterTime")
				assert.Nil(mt, err, "$clusterTime not found in response")

				// call function again
				err = sf.execute(mt, nil)
				assert.Nil(mt, err, "%v error: %v", sf.name, err)

				// find cluster time sent to server and assert it is the same as the one in the previous response
				sentClusterTimeVal, err := mt.GetStartedEvent().Command.LookupErr("$clusterTime")
				assert.Nil(mt, err, "$clusterTime not sent")
				replyClusterTimeDoc := replyClusterTimeVal.Document()
				sentClusterTimeDoc := sentClusterTimeVal.Document()
				assert.Equal(mt, replyClusterTimeDoc, sentClusterTimeDoc,
					"expected cluster time %v, got %v", replyClusterTimeDoc, sentClusterTimeDoc)
			})
		}
	})

	mt.RunOpts("4 explicit and implicit session arguments", noClientOpts, func(mt *mtest.T) {
		// lsid is included in commands with explicit and implicit sessions

		sessionFunctions := createFunctionsSlice()
		for _, sf := range sessionFunctions {
			mt.Run(sf.name, func(mt *mtest.T) {
				// explicit session
				sess, err := mt.Client.StartSession()
				assert.Nil(mt, err, "StartSession error: %v", err)
				defer sess.EndSession(context.Background())
				mt.ClearEvents()

				_ = sf.execute(mt, sess) // don't check error because we only care about lsid
				_, wantID := sess.ID().Lookup("id").Binary()
				gotID := extractSentSessionID(mt)
				assert.True(mt, bytes.Equal(wantID, gotID), "expected session ID %v, got %v", wantID, gotID)

				// implicit session
				_ = sf.execute(mt, nil)
				gotID = extractSentSessionID(mt)
				assert.NotNil(mt, gotID, "expected lsid, got nil")
			})
		}
	})

	mt.Run("5 session argument is for the right client", func(mt *mtest.T) {
		// a session can only be used in commands associated with the client that created it

		sessionFunctions := createFunctionsSlice()
		sess, err := mt.Client.StartSession()
		assert.Nil(mt, err, "StartSession error: %v", err)
		defer sess.EndSession(context.Background())

		for _, sf := range sessionFunctions {
			mt.Run(sf.name, func(mt *mtest.T) {
				err = sf.execute(mt, sess)
				assert.Equal(mt, mongo.ErrWrongClient, err, "expected error %v, got %v", mongo.ErrWrongClient, err)
			})
		}
	})

	const proseTest6 = "6 no further operations can be performed using a session after endSession has been called"
	mt.RunOpts(proseTest6, noClientOpts, func(mt *mtest.T) {
		// an ended session cannot be used in commands

		sessionFunctions := createFunctionsSlice()
		for _, sf := range sessionFunctions {
			mt.Run(sf.name, func(mt *mtest.T) {
				sess, err := mt.Client.StartSession()
				assert.Nil(mt, err, "StartSession error: %v", err)
				sess.EndSession(context.Background())

				err = sf.execute(mt, sess)
				assert.Equal(mt, session.ErrSessionEnded, err, "expected error %v, got %v", session.ErrSessionEnded, err)
			})
		}
	})

	mt.Run("7 authenticating as multiple users suppresses implicit sessions", func(mt *mtest.T) {
		mt.Skip("Go Driver does not allow simultaneous authentication with multiple users.")
	})

	mt.Run("8 client side cursor that exhausts the results on the initial query immediately returns the implicit session to the pool",
		func(mt *mtest.T) {
			// implicit sessions are returned to the server session pool

			doc := bson.D{{"x", 1}}
			_, err := mt.Coll.InsertOne(context.Background(), doc)
			assert.Nil(mt, err, "InsertOne error: %v", err)
			_, err = mt.Coll.InsertOne(context.Background(), doc)
			assert.Nil(mt, err, "InsertOne error: %v", err)

			// create a cursor that will hold onto an implicit session and record the sent session ID
			mt.ClearEvents()
			cursor, err := mt.Coll.Find(context.Background(), bson.D{})
			assert.Nil(mt, err, "Find error: %v", err)
			findID := extractSentSessionID(mt)
			assert.True(mt, cursor.Next(context.Background()), "expected Next true, got false")

			// execute another operation and verify the find session ID was reused
			_, err = mt.Coll.DeleteOne(context.Background(), bson.D{})
			assert.Nil(mt, err, "DeleteOne error: %v", err)
			deleteID := extractSentSessionID(mt)
			assert.Equal(mt, findID, deleteID, "expected session ID %v, got %v", findID, deleteID)
		})

	mt.Run("9 client side cursor that exhausts the results after a getMore immediately returns the implicit session to the pool",
		func(mt *mtest.T) {
			// Client-side cursor that exhausts the results after a getMore immediately returns the implicit session to the pool.

			var docs []any
			for i := 0; i < 5; i++ {
				docs = append(docs, bson.D{{"x", i}})
			}

			_, err := mt.Coll.InsertMany(context.Background(), docs)
			assert.Nil(mt, err, "InsertMany error: %v", err)

			// run a find that will hold onto the implicit session and record the session ID
			mt.ClearEvents()
			cursor, err := mt.Coll.Find(context.Background(), bson.D{}, options.Find().SetBatchSize(3))
			assert.Nil(mt, err, "Find error: %v", err)
			findID := extractSentSessionID(mt)

			// iterate past 4 documents, forcing a getMore. session should be returned to pool after getMore
			for i := 0; i < 4; i++ {
				assert.True(mt, cursor.Next(context.Background()), "Next returned false on iteration %v", i)
			}

			// execute another operation and verify the find session ID was reused
			_, err = mt.Coll.DeleteOne(context.Background(), bson.D{})
			assert.Nil(mt, err, "DeleteOne error: %v", err)
			deleteID := extractSentSessionID(mt)
			assert.Equal(mt, findID, deleteID, "expected session ID %v, got %v", findID, deleteID)
		})

	mt.Run("10 no remaining sessions are checked out after each functional test", func(mt *mtest.T) {
		mt.Skip("This is tested individually in each functional test.")
	})

	mt.Run("11 for every combination of topology and readPreference, ensure that find and getMore both send the same session id", func(mt *mtest.T) {
		var docs []any
		for i := 0; i < 3; i++ {
			docs = append(docs, bson.D{{"x", i}})
		}
		_, err := mt.Coll.InsertMany(context.Background(), docs)
		assert.Nil(mt, err, "InsertMany error: %v", err)

		// run a find that will hold onto an implicit session and record the session ID
		mt.ClearEvents()
		cursor, err := mt.Coll.Find(context.Background(), bson.D{}, options.Find().SetBatchSize(2))
		assert.Nil(mt, err, "Find error: %v", err)
		findID := extractSentSessionID(mt)
		assert.NotNil(mt, findID, "expected session ID for find, got nil")

		// iterate over all documents and record the session ID of the getMore
		for i := 0; i < 3; i++ {
			assert.True(mt, cursor.Next(context.Background()), "Next returned false on iteration %v", i)
		}
		getMoreID := extractSentSessionID(mt)
		assert.Equal(mt, findID, getMoreID, "expected session ID %v, got %v", findID, getMoreID)
	})

	sessallocopts := mtest.NewOptions().ClientOptions(options.Client().SetMaxPoolSize(1).SetRetryWrites(true).
		SetHosts(hosts[:1]))
	mt.RunOpts("14 implicit session allocation", sessallocopts, func(mt *mtest.T) {
		// TODO(GODRIVER-2844): Fix and unskip this test case.
		mt.Skip("Test fails frequently, skipping. See GODRIVER-2844")

		ops := map[string]func(ctx context.Context) error{
			"insert": func(ctx context.Context) error {
				_, err := mt.Coll.InsertOne(ctx, bson.D{})
				return err
			},
			"delete": func(ctx context.Context) error {
				_, err := mt.Coll.DeleteOne(ctx, bson.D{})
				return err
			},
			"update": func(ctx context.Context) error {
				_, err := mt.Coll.UpdateOne(ctx, bson.D{}, bson.D{{"$set", bson.D{{"a", 1}}}})
				return err
			},
			"bulkWrite": func(ctx context.Context) error {
				model := mongo.NewUpdateOneModel().
					SetFilter(bson.D{}).
					SetUpdate(bson.D{{"$set", bson.D{{"a", 1}}}})
				_, err := mt.Coll.BulkWrite(ctx, []mongo.WriteModel{model})
				return err
			},
			"findOneAndDelete": func(ctx context.Context) error {
				result := mt.Coll.FindOneAndDelete(ctx, bson.D{})
				if err := result.Err(); err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
					return err
				}
				return nil
			},
			"findOneAndUpdate": func(ctx context.Context) error {
				result := mt.Coll.FindOneAndUpdate(ctx, bson.D{},
					bson.D{{"$set", bson.D{{"a", 1}}}})

				if err := result.Err(); err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
					return err
				}
				return nil
			},
			"findOneAndReplace": func(ctx context.Context) error {
				result := mt.Coll.FindOneAndReplace(ctx, bson.D{}, bson.D{{"a", 1}})
				if err := result.Err(); err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
					return err
				}
				return nil
			},
			"find": func(ctx context.Context) error {
				cursor, err := mt.Coll.Find(ctx, bson.D{})
				if err != nil {
					return err
				}
				return cursor.All(ctx, &bson.A{})
			},
		}

		// maintainedOneSession asserts that exactly one session is used for all operations at least once
		// across the retries of this test.
		var maintainedOneSession bool

		// minimumSessionCount asserts the least amount of sessions used over all the retries of the
		// operations. For example, if we retry 5 times we could result in session use { 1, 2, 1, 1, 6 }. In
		// this case, minimumSessionCount should be 1.
		var minimumSessionCount int

		// limitedSessionUse asserts that the number of allocated sessions is strictly less than the number of
		// concurrent operations in every retry of this test. In this instance it would be less than (but NOT
		// equal to the number of operations).
		limitedSessionUse := true

		retrycount := 5
		for i := 1; i <= retrycount; i++ {
			errs, ctx := errgroup.WithContext(context.Background())

			// Execute the ops list concurrently.
			for cmd, op := range ops {
				op := op
				cmd := cmd
				errs.Go(func() error {
					if err := op(ctx); err != nil {
						return fmt.Errorf("error running %s operation: %w", cmd, err)
					}
					return nil
				})
			}
			err := errs.Wait()
			assert.Nil(mt, err, "expected no error, got: %v", err)

			// Get all started events and collect them by the session ID.
			set := make(map[string]bool)
			for _, event := range mt.GetAllStartedEvents() {
				lsid := event.Command.Lookup("lsid")
				set[lsid.String()] = true
			}

			setSize := len(set)
			if setSize == 1 {
				maintainedOneSession = true
			} else if setSize < minimumSessionCount || minimumSessionCount == 0 {
				// record the minimum number of sessions we used over all retries.
				minimumSessionCount = setSize
			}

			if setSize >= len(ops) {
				limitedSessionUse = false
			}
		}

		oneSessMsg := "expected one session across all %v operations for at least 1/%v retries, got: %v"
		assert.True(mt, maintainedOneSession, oneSessMsg, len(ops), retrycount, minimumSessionCount)

		limitedSessMsg := "expected session count to be less than the number of operations: %v"
		assert.True(mt, limitedSessionUse, limitedSessMsg, len(ops))
	})

	mt.ResetClient(options.Client())
	client := mt.Client
	heartbeatStarted := make(chan struct{}, 1)
	heartbeatSucceeded := make(chan struct{}, 1)
	var clusterTimeAdvanced uint32
	serverMonitor := &event.ServerMonitor{
		ServerHeartbeatStarted: func(*event.ServerHeartbeatStartedEvent) {
			if atomic.LoadUint32(&clusterTimeAdvanced) == 1 {
				select {
				case heartbeatStarted <- struct{}{}:
					// NOOP
				default:
					// NOOP
				}
			}
		},
		ServerHeartbeatSucceeded: func(*event.ServerHeartbeatSucceededEvent) {
			if atomic.LoadUint32(&clusterTimeAdvanced) == 1 {
				select {
				case heartbeatSucceeded <- struct{}{}:
					// NOOP
				default:
					// NOOP
				}
			}
		},
	}
	pingOpts := mtest.NewOptions().
		CreateCollection(false).
		ClientOptions(options.Client().
			SetServerMonitor(serverMonitor).
			SetHeartbeatInterval(500 * time.Millisecond). // Minimum interval
			SetDirect(true)).
		ClientType(mtest.Pinned)
	mt.RunOpts("20 Drivers do not gossip $clusterTime on SDAM commands", pingOpts, func(mt *mtest.T) {
		wait := func(mt *mtest.T, ch <-chan struct{}, label string) {
			mt.Helper()

			select {
			case <-ch:
			case <-time.After(5 * time.Second):
				mt.Fatalf("timed out waiting for %s", label)
			}
		}

		err := mt.Client.Ping(context.Background(), readpref.Primary())
		assert.NoError(mt, err, "expected no error, got: %v", err)

		_, err = client.Database("test").Collection("test").InsertOne(context.Background(), bson.D{{"advance", "$clusterTime"}})
		require.NoError(mt, err, "expected no error inserting document, got: %v", err)

		atomic.StoreUint32(&clusterTimeAdvanced, 1)
		wait(mt, heartbeatStarted, "ServerHeartbeatStartedEvent")
		wait(mt, heartbeatSucceeded, "ServerHeartbeatSucceededEvent")

		err = mt.Client.Ping(context.Background(), readpref.Primary())
		require.NoError(mt, err, "expected no error, got: %v", err)

		succeededEvents := mt.GetAllSucceededEvents()
		require.Len(mt, succeededEvents, 2, "expected 2 succeeded events, got: %v", len(succeededEvents))
		require.Equal(mt, "ping", succeededEvents[0].CommandName, "expected first command to be ping, got: %v", succeededEvents[0].CommandName)
		initialClusterTime, err := succeededEvents[0].Reply.LookupErr("$clusterTime")
		require.NoError(mt, err, "$clusterTime not found in response")

		startedEvents := mt.GetAllStartedEvents()
		require.Len(mt, startedEvents, 2, "expected 2 started events, got: %v", len(startedEvents))
		require.Equal(mt, "ping", startedEvents[1].CommandName, "expected second command to be ping, got: %v", startedEvents[1].CommandName)
		currentClusterTime, err := startedEvents[1].Command.LookupErr("$clusterTime")
		require.NoError(mt, err, "$clusterTime not found in command")
		assert.Equal(mt, initialClusterTime, currentClusterTime, "expected same cluster time, got %v and %v", initialClusterTime, currentClusterTime)
	})
}

func TestSessionsProse_21_SettingSnapshotTimeWithoutSnapshot(t *testing.T) {
	// 21. Having snapshotTime set and snapshot set to false is not allowed.
	mtOpts := mtest.
		NewOptions().
		MinServerVersion("5.0").
		Topologies(mtest.ReplicaSet, mtest.Sharded)

	mt := mtest.New(t, mtOpts)

	mt.Setup()

	// Start a session by calling startSession with snapshot = false and
	// snapshotTime = new Timestamp(1).
	sessOpts := options.Session().SetSnapshot(false).SetSnapshotTime(bson.Timestamp{T: 1})

	_, err := mt.Client.StartSession(sessOpts)
	require.Error(t, err)
	require.Contains(t, err.Error(), "snapshotTime cannot be set when snapshot is false")
}

func TestSessionsProse_22_SnapshotTimeGetterReturnsErrorForNonSnapshotSessions(t *testing.T) {
	// 22. Retrieving `snapshotTime` on a non-snapshot session raises an error
	t.Skip("Skipping test for prose 22; Go driver does not have a getter that raises an error.")
}

func TestSessionsProse_23_EnsureSnapshotTimeIsImmutable(t *testing.T) {
	// 23. Ensure `snapshotTime` is Read-Only

	mtOpts := mtest.
		NewOptions().
		MinServerVersion("5.0").
		Topologies(mtest.ReplicaSet, mtest.Sharded)

	mt := mtest.New(t, mtOpts)

	mt.Setup()

	sess, err := mt.Client.StartSession(options.Session().SetSnapshot(false))
	require.NoError(mt, err)
	defer sess.EndSession(context.Background())

	// Verify initial state
	require.Empty(mt, sess.SnapshotTime())

	// Attempt mutation through one GetClientSessionFromSession call
	snapshotTime := sess.SnapshotTime()
	snapshotTime.I = 1

	// Second GetClientSessionFromSession call should return independent copy
	require.Empty(mt, sess.SnapshotTime())
}

type sessionFunction struct {
	name   string
	target string
	fnName string
	params []any // should not include context
}

func (sf sessionFunction) execute(mt *mtest.T, sess *mongo.Session) error {
	var target reflect.Value
	switch sf.target {
	case "client":
		target = reflect.ValueOf(mt.Client)
	case "database":
		// use a different database for drops because any executed after the drop will get "database not found"
		// errors on sharded clusters
		if sf.name != "drop database" {
			target = reflect.ValueOf(mt.DB)
			break
		}
		target = reflect.ValueOf(mt.Client.Database("sessionsTestsDropDatabase"))
	case "collection":
		target = reflect.ValueOf(mt.Coll)
	case "indexView":
		target = reflect.ValueOf(mt.Coll.Indexes())
	default:
		mt.Fatalf("unrecognized target: %v", sf.target)
	}

	fn := target.MethodByName(sf.fnName)
	paramsValues := interfaceSliceToValueSlice(sf.params)

	if sess != nil {
		return mongo.WithSession(context.Background(), sess, func(sc context.Context) error {
			valueArgs := []reflect.Value{reflect.ValueOf(sc)}
			valueArgs = append(valueArgs, paramsValues...)
			returnValues := fn.Call(valueArgs)
			return extractReturnError(returnValues)
		})
	}
	valueArgs := []reflect.Value{reflect.ValueOf(context.Background())}
	valueArgs = append(valueArgs, paramsValues...)
	returnValues := fn.Call(valueArgs)
	return extractReturnError(returnValues)
}

func createFunctionsSlice() []sessionFunction {
	insertManyDocs := []any{bson.D{{"x", 1}}}
	fooIndex := mongo.IndexModel{
		Keys:    bson.D{{"foo", -1}},
		Options: options.Index().SetName("fooIndex"),
	}
	manyIndexes := []mongo.IndexModel{fooIndex}
	updateDoc := bson.D{{"$inc", bson.D{{"x", 1}}}}

	return []sessionFunction{
		{"list databases", "client", "ListDatabases", []any{bson.D{}}},
		{"insert one", "collection", "InsertOne", []any{bson.D{{"x", 1}}}},
		{"insert many", "collection", "InsertMany", []any{insertManyDocs}},
		{"delete one", "collection", "DeleteOne", []any{bson.D{}}},
		{"delete many", "collection", "DeleteMany", []any{bson.D{}}},
		{"update one", "collection", "UpdateOne", []any{bson.D{}, updateDoc}},
		{"update many", "collection", "UpdateMany", []any{bson.D{}, updateDoc}},
		{"replace one", "collection", "ReplaceOne", []any{bson.D{}, bson.D{}}},
		{"aggregate", "collection", "Aggregate", []any{mongo.Pipeline{}}},
		{"estimated document count", "collection", "EstimatedDocumentCount", nil},
		{"distinct", "collection", "Distinct", []any{"field", bson.D{}}},
		{"find", "collection", "Find", []any{bson.D{}}},
		{"find one and delete", "collection", "FindOneAndDelete", []any{bson.D{}}},
		{"find one and replace", "collection", "FindOneAndReplace", []any{bson.D{}, bson.D{}}},
		{"find one and update", "collection", "FindOneAndUpdate", []any{bson.D{}, updateDoc}},
		{"drop collection", "collection", "Drop", nil},
		{"list collections", "database", "ListCollections", []any{bson.D{}}},
		{"drop database", "database", "Drop", nil},
		{"create one index", "indexView", "CreateOne", []any{fooIndex}},
		{"create many indexes", "indexView", "CreateMany", []any{manyIndexes}},
		{"drop one index", "indexView", "DropOne", []any{"barIndex"}},
		{"drop all indexes", "indexView", "DropAll", nil},
		{"list indexes", "indexView", "List", nil},
	}
}

func assertCollectionCount(mt *mtest.T, expectedCount int64) {
	mt.Helper()

	count, err := mt.Coll.CountDocuments(context.Background(), bson.D{})
	require.NoError(mt, err, "CountDocuments error")
	assert.Equal(mt, expectedCount, count, "expected CountDocuments result %v, got %v", expectedCount, count)
}

func sessionIDsEqual(mt *mtest.T, id1, id2 bson.Raw) bool {
	first, err := id1.LookupErr("id")
	assert.Nil(mt, err, "id not found in document %v", id1)
	second, err := id2.LookupErr("id")
	assert.Nil(mt, err, "id not found in document %v", id2)

	_, firstUUID := first.Binary()
	_, secondUUID := second.Binary()
	return bytes.Equal(firstUUID, secondUUID)
}

func interfaceSliceToValueSlice(args []any) []reflect.Value {
	vals := make([]reflect.Value, 0, len(args))
	for _, arg := range args {
		vals = append(vals, reflect.ValueOf(arg))
	}
	return vals
}

func extractReturnError(returnValues []reflect.Value) error {
	errVal := returnValues[len(returnValues)-1]
	switch converted := errVal.Interface().(type) {
	case error:
		return converted
	case *mongo.SingleResult:
		return converted.Err()
	case *mongo.DistinctResult:
		return converted.Err()
	default:
		return nil
	}
}

func extractSentSessionID(mt *mtest.T) []byte {
	event := mt.GetStartedEvent()
	if event == nil {
		return nil
	}
	lsid, err := event.Command.LookupErr("lsid")
	if err != nil {
		return nil
	}

	_, data := lsid.Document().Lookup("id").Binary()
	return data
}
