// Copyright (C) MongoDB, Inc. 2023-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 (
	"context"
	"os"
	"sync"
	"testing"
	"time"

	"go.mongodb.org/mongo-driver/v2/bson"
	"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/uuid"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
	"go.mongodb.org/mongo-driver/v2/mongo/readconcern"
	"go.mongodb.org/mongo-driver/v2/mongo/writeconcern"
)

func TestSearchIndexProse(t *testing.T) {
	t.Parallel()

	const timeout = 5 * time.Minute

	uri := os.Getenv("SEARCH_INDEX_URI")
	if uri == "" {
		t.Skip("skipping")
	}

	opts := options.Client().ApplyURI(uri).SetTimeout(timeout)
	mt := mtest.New(t, mtest.NewOptions().ClientOptions(opts).MinServerVersion("7.0").Topologies(mtest.ReplicaSet))

	mt.Run("case 1: Driver can successfully create and list search indexes", func(mt *mtest.T) {
		ctx := context.Background()

		_, err := mt.Coll.InsertOne(ctx, bson.D{})
		require.NoError(mt, err, "failed to insert")

		view := mt.Coll.SearchIndexes()

		definition := bson.D{{"mappings", bson.D{{"dynamic", false}}}}
		searchName := "test-search-index"
		opts := options.SearchIndexes().SetName(searchName)
		index, err := view.CreateOne(ctx, mongo.SearchIndexModel{
			Definition: definition,
			Options:    opts,
		})
		require.NoError(mt, err, "failed to create index")
		require.Equal(mt, searchName, index, "unmatched name")

		awaitCtx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
		defer cancel()

		var doc bson.Raw
		for doc == nil {
			cursor, err := view.List(awaitCtx, opts)
			require.NoError(mt, err, "failed to list")

			if !cursor.Next(awaitCtx) {
				break
			}
			name := cursor.Current.Lookup("name").StringValue()
			queryable := cursor.Current.Lookup("queryable").Boolean()
			if name == searchName && queryable {
				doc = cursor.Current
			} else {
				mt.Logf("cursor: %s, sleep 5 seconds...", cursor.Current.String())
				time.Sleep(5 * time.Second)
			}
		}
		require.NotNil(mt, doc, "got empty document")
		actual := doc.Lookup("latestDefinition", "mappings", "dynamic").Boolean()
		assert.False(mt, actual, "expected latestDefinition.mappings.dynamic to be false")
	})

	mt.Run("case 2: Driver can successfully create multiple indexes in batch", func(mt *mtest.T) {
		ctx := context.Background()

		_, err := mt.Coll.InsertOne(ctx, bson.D{})
		require.NoError(mt, err, "failed to insert")

		view := mt.Coll.SearchIndexes()

		definition := bson.D{{"mappings", bson.D{{"dynamic", false}}}}
		models := []mongo.SearchIndexModel{
			{
				Definition: definition,
				Options:    options.SearchIndexes().SetName("test-search-index-1"),
			},
			{
				Definition: definition,
				Options:    options.SearchIndexes().SetName("test-search-index-2"),
			},
		}
		indexes, err := view.CreateMany(ctx, models)
		require.NoError(mt, err, "failed to create index")
		require.Equal(mt, len(indexes), 2, "expected 2 indexes")
		for _, model := range models {
			args, err := mongoutil.NewOptions[options.SearchIndexesOptions](model.Options)
			require.NoError(mt, err, "failed to construct options from builder")

			require.Contains(mt, indexes, *args.Name)
		}

		getDocument := func(ctx context.Context, opts *options.SearchIndexesOptionsBuilder) bson.Raw {
			for {
				cursor, err := view.List(ctx, opts)
				require.NoError(mt, err, "failed to list")

				if !cursor.Next(ctx) {
					return nil
				}
				name := cursor.Current.Lookup("name").StringValue()
				queryable := cursor.Current.Lookup("queryable").Boolean()

				args, err := mongoutil.NewOptions[options.SearchIndexesOptions](opts)
				require.NoError(mt, err, "failed to construct options from builder")

				if name == *args.Name && queryable {
					return cursor.Current
				}
				mt.Logf("cursor: %s, sleep 5 seconds...", cursor.Current.String())
				time.Sleep(5 * time.Second)
			}
		}

		var wg sync.WaitGroup
		wg.Add(len(models))
		for i := range models {
			go func(opts *options.SearchIndexesOptionsBuilder) {
				defer wg.Done()

				ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
				defer cancel()

				doc := getDocument(ctx, opts)
				require.NotNil(mt, doc, "got empty document")

				args, err := mongoutil.NewOptions[options.SearchIndexesOptions](opts)
				require.NoError(mt, err, "failed to construct options from builder")

				assert.Equal(mt, *args.Name, doc.Lookup("name").StringValue(), "unmatched name")

				actual := doc.Lookup("latestDefinition", "mappings", "dynamic").Boolean()
				assert.False(mt, actual, "expected latestDefinition.mappings.dynamic to be false")
			}(models[i].Options)
		}
		wg.Wait()
	})

	mt.Run("case 3: Driver can successfully drop search indexes", func(mt *mtest.T) {
		ctx := context.Background()

		_, err := mt.Coll.InsertOne(ctx, bson.D{})
		require.NoError(mt, err, "failed to insert")

		view := mt.Coll.SearchIndexes()

		definition := bson.D{{"mappings", bson.D{{"dynamic", false}}}}
		searchName := "test-search-index"
		opts := options.SearchIndexes().SetName(searchName)
		index, err := view.CreateOne(ctx, mongo.SearchIndexModel{
			Definition: definition,
			Options:    opts,
		})
		require.NoError(mt, err, "failed to create index")
		require.Equal(mt, searchName, index, "unmatched name")

		createOneCtx, createOneCancel := context.WithTimeout(context.Background(), 1*time.Minute)
		defer createOneCancel()
		var doc bson.Raw
		for doc == nil {
			cursor, err := view.List(createOneCtx, opts)
			require.NoError(mt, err, "failed to list")

			if !cursor.Next(createOneCtx) {
				break
			}
			name := cursor.Current.Lookup("name").StringValue()
			queryable := cursor.Current.Lookup("queryable").Boolean()
			if name == searchName && queryable {
				doc = cursor.Current
			} else {
				mt.Logf("cursor: %s, sleep 5 seconds...", cursor.Current.String())
				time.Sleep(5 * time.Second)
			}
		}
		require.NotNil(mt, doc, "got empty document")

		err = view.DropOne(ctx, searchName)
		require.NoError(mt, err, "failed to drop index")
		dropOneCtx, dropOneCancel := context.WithTimeout(context.Background(), 1*time.Minute)
		defer dropOneCancel()
		for {
			cursor, err := view.List(dropOneCtx, opts)
			require.NoError(mt, err, "failed to list")

			if !cursor.Next(dropOneCtx) {
				break
			}
			mt.Logf("cursor: %s, sleep 5 seconds...", cursor.Current.String())
			time.Sleep(5 * time.Second)
		}
	})

	mt.Run("case 4: Driver can update a search index", func(mt *mtest.T) {
		ctx := context.Background()

		_, err := mt.Coll.InsertOne(ctx, bson.D{})
		require.NoError(mt, err, "failed to insert")

		view := mt.Coll.SearchIndexes()

		definition := bson.D{{"mappings", bson.D{{"dynamic", false}}}}
		searchName := "test-search-index"
		opts := options.SearchIndexes().SetName(searchName)
		index, err := view.CreateOne(ctx, mongo.SearchIndexModel{
			Definition: definition,
			Options:    opts,
		})
		require.NoError(mt, err, "failed to create index")
		require.Equal(mt, searchName, index, "unmatched name")

		createOneCtx, createOneCancel := context.WithTimeout(context.Background(), 1*time.Minute)
		defer createOneCancel()
		var doc bson.Raw
		for doc == nil {
			cursor, err := view.List(createOneCtx, opts)
			require.NoError(mt, err, "failed to list")

			if !cursor.Next(createOneCtx) {
				break
			}
			name := cursor.Current.Lookup("name").StringValue()
			queryable := cursor.Current.Lookup("queryable").Boolean()
			if name == searchName && queryable {
				doc = cursor.Current
			} else {
				mt.Logf("cursor: %s, sleep 5 seconds...", cursor.Current.String())
				time.Sleep(5 * time.Second)
			}
		}
		require.NotNil(mt, doc, "got empty document")

		definition = bson.D{{"mappings", bson.D{{"dynamic", true}}}}
		err = view.UpdateOne(ctx, searchName, definition)
		require.NoError(mt, err, "failed to update index")
		updateOneCtx, updateOneCancel := context.WithTimeout(context.Background(), 2*time.Minute)
		defer updateOneCancel()
		doc = nil
		for doc == nil {
			cursor, err := view.List(updateOneCtx, opts)
			require.NoError(mt, err, "failed to list")

			if !cursor.Next(updateOneCtx) {
				break
			}
			name := cursor.Current.Lookup("name").StringValue()
			queryable := cursor.Current.Lookup("queryable").Boolean()
			status := cursor.Current.Lookup("status").StringValue()
			if name == searchName && queryable && status == "READY" {
				doc = cursor.Current
			} else {
				mt.Logf("cursor: %s, sleep 5 seconds...", cursor.Current.String())
				time.Sleep(5 * time.Second)
			}
		}
		require.NotNil(mt, doc, "got empty document")
		require.True(mt, doc.Lookup("latestDefinition", "mappings", "dynamic").Boolean(),
			"expected latestDefinition.mappings.dynamic to be true")
	})

	mt.Run("case 5: dropSearchIndex suppresses namespace not found errors", func(mt *mtest.T) {
		ctx := context.Background()

		id, err := uuid.New()
		require.NoError(mt, err)

		collection := mt.CreateCollection(mtest.Collection{
			Name: id.String(),
		}, false)

		err = collection.SearchIndexes().DropOne(ctx, "foo")
		require.NoError(mt, err)
	})

	mt.RunOpts("case 6: Driver can successfully create and list search indexes with non-default readConcern and writeConcern",
		mtest.NewOptions().CollectionOptions(options.Collection().SetWriteConcern(writeconcern.W1()).SetReadConcern(readconcern.Majority())),
		func(mt *mtest.T) {
			ctx := context.Background()

			_, err := mt.Coll.InsertOne(ctx, bson.D{})
			require.NoError(mt, err, "failed to insert")

			view := mt.Coll.SearchIndexes()

			definition := bson.D{{"mappings", bson.D{{"dynamic", false}}}}
			const searchName = "test-search-index-case6"
			opts := options.SearchIndexes().SetName(searchName)
			index, err := view.CreateOne(ctx, mongo.SearchIndexModel{
				Definition: definition,
				Options:    opts,
			})
			require.NoError(mt, err, "failed to create index")
			require.Equal(mt, searchName, index, "unmatched name")
			awaitCtx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
			defer cancel()
			var doc bson.Raw
			for doc == nil {
				cursor, err := view.List(awaitCtx, opts)
				require.NoError(mt, err, "failed to list")

				if !cursor.Next(awaitCtx) {
					break
				}
				name := cursor.Current.Lookup("name").StringValue()
				queryable := cursor.Current.Lookup("queryable").Boolean()
				if name == searchName && queryable {
					doc = cursor.Current
				} else {
					mt.Logf("cursor: %s, sleep 5 seconds...", cursor.Current.String())
					time.Sleep(5 * time.Second)
				}
			}
			require.NotNil(mt, doc, "got empty document")
			actual := doc.Lookup("latestDefinition", "mappings", "dynamic").Boolean()
			assert.False(mt, actual, "expected latestDefinition.mappings.dynamic to be false")
		})

	case7CollName, err := uuid.New()
	assert.NoError(mt, err, "failed to create random collection name for case #7")

	mt.RunOpts("case 7: Driver can successfully handle search index types when creating indexes",
		mtest.NewOptions().CollectionName(case7CollName.String()),
		func(mt *mtest.T) {
			ctx := context.Background()

			_, err := mt.Coll.InsertOne(ctx, bson.D{})
			require.NoError(mt, err, "failed to insert")

			view := mt.Coll.SearchIndexes()

			definition := bson.D{{"mappings", bson.D{{"dynamic", false}}}}
			indexName := "test-search-index-case7-implicit"
			opts := options.SearchIndexes().SetName(indexName)
			index, err := view.CreateOne(ctx, mongo.SearchIndexModel{
				Definition: definition,
				Options:    opts,
			})
			require.NoError(mt, err, "failed to create index")
			require.Equal(mt, indexName, index, "unmatched name")
			implicitCtx, implicitCancel := context.WithTimeout(context.Background(), 1*time.Minute)
			defer implicitCancel()
			var doc bson.Raw
			for doc == nil {
				cursor, err := view.List(implicitCtx, opts)
				require.NoError(mt, err, "failed to list")

				if !cursor.Next(implicitCtx) {
					break
				}
				name := cursor.Current.Lookup("name").StringValue()
				queryable := cursor.Current.Lookup("queryable").Boolean()
				indexType := cursor.Current.Lookup("type").StringValue()
				if name == indexName && queryable {
					doc = cursor.Current
					assert.Equal(mt, indexType, "search")
				} else {
					mt.Logf("cursor: %s, sleep 5 seconds...", cursor.Current.String())
					time.Sleep(5 * time.Second)
				}
			}

			indexName = "test-search-index-case7-explicit"
			opts = options.SearchIndexes().SetName(indexName).SetType("search")
			index, err = view.CreateOne(ctx, mongo.SearchIndexModel{
				Definition: definition,
				Options:    opts,
			})
			require.NoError(mt, err, "failed to create index")
			require.Equal(mt, indexName, index, "unmatched name")
			explicitCtx, explicitCancel := context.WithTimeout(context.Background(), 1*time.Minute)
			defer explicitCancel()
			doc = nil
			for doc == nil {
				cursor, err := view.List(explicitCtx, opts)
				require.NoError(mt, err, "failed to list")

				if !cursor.Next(explicitCtx) {
					break
				}
				name := cursor.Current.Lookup("name").StringValue()
				queryable := cursor.Current.Lookup("queryable").Boolean()
				indexType := cursor.Current.Lookup("type").StringValue()
				if name == indexName && queryable {
					doc = cursor.Current
					assert.Equal(mt, indexType, "search")
				} else {
					mt.Logf("cursor: %s, sleep 5 seconds...", cursor.Current.String())
					time.Sleep(5 * time.Second)
				}
			}

			indexName = "test-search-index-case7-vector"
			type vectorDefinitionField struct {
				Type          string `bson:"type"`
				Path          string `bson:"path"`
				NumDimensions int    `bson:"numDimensions"`
				Similarity    string `bson:"similarity"`
			}

			type vectorDefinition struct {
				Fields []vectorDefinitionField `bson:"fields"`
			}

			opts = options.SearchIndexes().SetName(indexName).SetType("vectorSearch")
			index, err = view.CreateOne(ctx, mongo.SearchIndexModel{
				Definition: vectorDefinition{
					Fields: []vectorDefinitionField{{"vector", "path", 1536, "euclidean"}},
				},
				Options: opts,
			})
			require.NoError(mt, err, "failed to create index")
			require.Equal(mt, indexName, index, "unmatched name")
			vectorCtx, vectorCancel := context.WithTimeout(context.Background(), 1*time.Minute)
			defer vectorCancel()
			doc = nil
			for doc == nil {
				cursor, err := view.List(vectorCtx, opts)
				require.NoError(mt, err, "failed to list")

				if !cursor.Next(vectorCtx) {
					break
				}
				name := cursor.Current.Lookup("name").StringValue()
				queryable := cursor.Current.Lookup("queryable").Boolean()
				indexType := cursor.Current.Lookup("type").StringValue()
				if name == indexName && queryable {
					doc = cursor.Current
					assert.Equal(mt, indexType, "vectorSearch")
				} else {
					mt.Logf("cursor: %s, sleep 5 seconds...", cursor.Current.String())
					time.Sleep(5 * time.Second)
				}
			}
		})

	case8CollName, err := uuid.New()
	assert.NoError(mt, err, "failed to create random collection name for case #8")

	mt.RunOpts("case 8: Driver requires explicit type to create a vector search index",
		mtest.NewOptions().CollectionName(case8CollName.String()),
		func(mt *mtest.T) {
			ctx := context.Background()

			_, err := mt.Coll.InsertOne(ctx, bson.D{})
			require.NoError(mt, err, "failed to insert")

			view := mt.Coll.SearchIndexes()

			type vectorDefinitionField struct {
				Type          string `bson:"type"`
				Path          string `bson:"path"`
				NumDimensions int    `bson:"numDimensions"`
				Similarity    string `bson:"similarity"`
			}

			type vectorDefinition struct {
				Fields []vectorDefinitionField `bson:"fields"`
			}

			const indexName = "test-search-index-case7-vector"
			opts := options.SearchIndexes().SetName(indexName)
			_, err = view.CreateOne(ctx, mongo.SearchIndexModel{
				Definition: vectorDefinition{
					Fields: []vectorDefinitionField{{"vector", "plot_embedding", 1536, "euclidean"}},
				},
				Options: opts,
			})
			assert.ErrorContains(mt, err, "Attribute mappings missing")
		})

	mt.Run("case 9: Drivers use server default for unspecified name (`default`) and type (`search`)", func(mt *mtest.T) {
		cases := []struct {
			name string
			opts *options.SearchIndexesOptionsBuilder
		}{
			{name: "empty options", opts: options.SearchIndexes()},
			{name: "nil options", opts: nil},
		}

		for _, tc := range cases {
			mt.Run(tc.name, func(mt *mtest.T) {
				view := mt.Coll.SearchIndexes()
				definition := bson.D{
					{"mappings", bson.D{
						{"dynamic", true},
					}},
				}

				indexName, err := view.CreateOne(context.Background(), mongo.SearchIndexModel{
					Definition: definition,
					Options:    tc.opts,
				})
				require.NoError(mt, err, "failed to create index")
				require.Equal(mt, "default", indexName)

				ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
				defer cancel()

				var doc bson.Raw
				for doc == nil {
					cursor, err := view.List(ctx, tc.opts)
					require.NoError(mt, err, "failed to list")

					if !cursor.Next(ctx) {
						break
					}
					name := cursor.Current.Lookup("name").StringValue()
					queryable := cursor.Current.Lookup("queryable").Boolean()
					indexType := cursor.Current.Lookup("type").StringValue()
					if name == indexName && queryable {
						doc = cursor.Current
						require.Equal(mt, indexType, "search")
					} else {
						mt.Logf("cursor: %s, sleep 5 seconds...", cursor.Current.String())
						time.Sleep(5 * time.Second)
					}
				}
			})
		}
	})
}
