// Licensed to the LF AI & Data foundation under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package commit_timestamp

import (
	"context"
	"fmt"
	"path"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/suite"
	clientv3 "go.etcd.io/etcd/client/v3"
	"google.golang.org/protobuf/proto"

	"github.com/milvus-io/milvus-proto/go-api/v3/commonpb"
	"github.com/milvus-io/milvus-proto/go-api/v3/milvuspb"
	"github.com/milvus-io/milvus-proto/go-api/v3/schemapb"
	"github.com/milvus-io/milvus/pkg/v3/mlog"
	"github.com/milvus-io/milvus/pkg/v3/proto/datapb"
	"github.com/milvus-io/milvus/pkg/v3/util/funcutil"
	"github.com/milvus-io/milvus/pkg/v3/util/merr"
	"github.com/milvus-io/milvus/pkg/v3/util/metric"
	"github.com/milvus-io/milvus/pkg/v3/util/tsoutil"
	"github.com/milvus-io/milvus/tests/integration"
)

const dim = 128

func TestCommitTimestampSuite(t *testing.T) {
	suite.Run(t, new(CommitTimestampSuite))
}

type CommitTimestampSuite struct {
	integration.MiniClusterSuite
}

// ─── Helpers ──────────────────────────────────────────────────────────────

// setCommitTimestamp reads a segment's metadata from etcd, sets its
// CommitTimestamp to commitTs, then writes it back. This simulates an
// import segment without going through the actual import pipeline.
func (s *CommitTimestampSuite) setCommitTimestamp(
	collectionID int64,
	commitTs uint64,
) []int64 {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	prefix := path.Join(s.Cluster.RootPath(), "meta/datacoord-meta/s",
		fmt.Sprintf("%d", collectionID)) + "/"

	resp, err := s.Cluster.EtcdCli.Get(ctx, prefix, clientv3.WithPrefix())
	s.Require().NoError(err, "failed to list segments from etcd")
	s.Require().NotEmpty(resp.Kvs, "no segments found in etcd")

	var segmentIDs []int64
	for _, kv := range resp.Kvs {
		var seg datapb.SegmentInfo
		err := proto.Unmarshal(kv.Value, &seg)
		if err != nil {
			continue
		}
		if seg.GetState() != commonpb.SegmentState_Flushed && seg.GetState() != commonpb.SegmentState_Flushing {
			continue
		}
		if len(seg.GetBinlogs()) == 0 {
			continue
		}

		mlog.Info(context.TODO(), "setCommitTimestamp: modifying segment",
			mlog.FieldSegmentID(seg.GetID()),
			mlog.Uint64("commitTs", commitTs))

		seg.CommitTimestamp = commitTs

		data, err := proto.Marshal(&seg)
		s.Require().NoError(err)
		_, err = s.Cluster.EtcdCli.Put(ctx, string(kv.Key), string(data))
		s.Require().NoError(err)
		segmentIDs = append(segmentIDs, seg.GetID())
	}
	s.Require().NotEmpty(segmentIDs, "no flushed segments were modified")
	return segmentIDs
}

// createCollectionAndInsert creates a collection, inserts rows, and flushes.
// Returns (collectionName, collectionID).
func (s *CommitTimestampSuite) createCollectionAndInsert(
	ctx context.Context,
	rowNum int,
) (string, int64) {
	collName := "CommitTs_" + funcutil.RandomString(6)

	schema := integration.ConstructSchema(collName, dim, false)
	marshaledSchema, err := proto.Marshal(schema)
	s.Require().NoError(err)

	createResp, err := s.Cluster.MilvusClient.CreateCollection(ctx, &milvuspb.CreateCollectionRequest{
		CollectionName: collName,
		Schema:         marshaledSchema,
		ShardsNum:      1,
	})
	s.Require().NoError(err)
	s.Require().True(merr.Ok(createResp))

	pkColumn := integration.NewInt64FieldDataWithStart(integration.Int64Field, rowNum, 1)
	vecColumn := integration.NewFloatVectorFieldData(integration.FloatVecField, rowNum, dim)
	insertResp, err := s.Cluster.MilvusClient.Insert(ctx, &milvuspb.InsertRequest{
		CollectionName: collName,
		FieldsData:     []*schemapb.FieldData{pkColumn, vecColumn},
		NumRows:        uint32(rowNum),
	})
	s.Require().NoError(err)
	s.Require().True(merr.Ok(insertResp.GetStatus()))

	flushResp, err := s.Cluster.MilvusClient.Flush(ctx, &milvuspb.FlushRequest{
		CollectionNames: []string{collName},
	})
	s.Require().NoError(err)
	s.Require().True(merr.Ok(flushResp.GetStatus()))
	segIDs := flushResp.GetCollSegIDs()[collName].GetData()
	flushTs := flushResp.GetCollFlushTs()[collName]
	s.WaitForFlush(ctx, segIDs, flushTs, "", collName)

	showResp, err := s.Cluster.MilvusClient.ShowCollections(ctx, &milvuspb.ShowCollectionsRequest{
		CollectionNames: []string{collName},
	})
	s.Require().NoError(err)
	s.Require().True(merr.Ok(showResp.GetStatus()))
	collectionID := showResp.GetCollectionIds()[0]

	return collName, collectionID
}

// buildIndexAndLoad creates an index and loads the collection.
func (s *CommitTimestampSuite) buildIndexAndLoad(ctx context.Context, collName string) {
	indexResp, err := s.Cluster.MilvusClient.CreateIndex(ctx, &milvuspb.CreateIndexRequest{
		CollectionName: collName,
		FieldName:      integration.FloatVecField,
		IndexName:      "vec_idx",
		ExtraParams:    integration.ConstructIndexParam(dim, integration.IndexFaissIvfFlat, metric.L2),
	})
	s.Require().NoError(err)
	s.Require().True(merr.Ok(indexResp))
	s.WaitForIndexBuilt(ctx, collName, integration.FloatVecField)

	loadResp, err := s.Cluster.MilvusClient.LoadCollection(ctx, &milvuspb.LoadCollectionRequest{
		CollectionName: collName,
	})
	s.Require().NoError(err)
	s.Require().True(merr.Ok(loadResp))
	s.WaitForLoad(ctx, collName)
}

// queryCountWithTs queries count(*) with an explicit guarantee timestamp.
// Use guaranteeTs=0 for strong consistency.
func (s *CommitTimestampSuite) queryCountWithTs(ctx context.Context, collName string, guaranteeTs uint64) int64 {
	req := &milvuspb.QueryRequest{
		CollectionName: collName,
		Expr:           "",
		OutputFields:   []string{"count(*)"},
		QueryParams: []*commonpb.KeyValuePair{
			{Key: "reduce_stop_for_best", Value: "false"},
		},
	}
	if guaranteeTs > 0 {
		req.GuaranteeTimestamp = guaranteeTs
	} else {
		req.ConsistencyLevel = commonpb.ConsistencyLevel_Strong
	}

	queryResp, err := s.Cluster.MilvusClient.Query(ctx, req)
	s.Require().NoError(err)
	s.Require().True(merr.Ok(queryResp.GetStatus()), queryResp.GetStatus().GetReason())

	for _, field := range queryResp.GetFieldsData() {
		if field.GetFieldName() == "count(*)" {
			return field.GetScalars().GetLongData().GetData()[0]
		}
	}
	s.Fail("count(*) field not found in query response")
	return 0
}

// deleteByPKs deletes the first N PKs (1-based) from the collection.
func (s *CommitTimestampSuite) deleteByPKs(ctx context.Context, collName string, count int) {
	pks := make([]string, count)
	for i := 0; i < count; i++ {
		pks[i] = strconv.FormatInt(int64(i+1), 10)
	}
	expr := fmt.Sprintf("%s in [%s]", integration.Int64Field, strings.Join(pks, ","))
	deleteResp, err := s.Cluster.MilvusClient.Delete(ctx, &milvuspb.DeleteRequest{
		CollectionName: collName,
		Expr:           expr,
	})
	s.Require().NoError(err)
	s.Require().True(merr.Ok(deleteResp.GetStatus()))
}

// searchWithTs performs a search with explicit guarantee timestamp. Returns result count.
func (s *CommitTimestampSuite) searchWithTs(ctx context.Context, collName string, guaranteeTs uint64, topk int) int {
	params := integration.GetSearchParams(integration.IndexFaissIvfFlat, metric.L2)
	searchReq := integration.ConstructSearchRequest("", collName, "",
		integration.FloatVecField, schemapb.DataType_FloatVector, nil,
		metric.L2, params, 1, dim, topk, -1)
	searchReq.GuaranteeTimestamp = guaranteeTs

	searchResult, err := s.Cluster.MilvusClient.Search(ctx, searchReq)
	s.Require().NoError(err)
	s.Require().True(merr.Ok(searchResult.GetStatus()), searchResult.GetStatus().GetReason())

	return len(searchResult.GetResults().GetScores())
}

// ─── MVCC Visibility ──────────────────────────────────────────────────────

func (s *CommitTimestampSuite) TestMVCC_Visibility() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	const rowNum = 100

	collName, collectionID := s.createCollectionAndInsert(ctx, rowNum)

	tBefore := tsoutil.ComposeTSByTime(time.Now())

	// Set commit_ts to a future time to test MVCC
	tCommit := tsoutil.ComposeTSByTime(time.Now().Add(10 * time.Second))
	tAfterCommit := tsoutil.ComposeTSByTime(time.Now().Add(20 * time.Second))
	s.setCommitTimestamp(collectionID, tCommit)

	s.buildIndexAndLoad(ctx, collName)

	// Query with guarantee_ts < commit_ts → 0 rows
	count := s.queryCountWithTs(ctx, collName, tBefore)
	s.Equal(int64(0), count,
		"MVCC: query before commit_ts should return 0 rows")

	// Strong consistency query (guarantee_ts = now < commit_ts) → 0 rows
	count = s.queryCountWithTs(ctx, collName, 0)
	s.Equal(int64(0), count,
		"MVCC: strong consistency query should return 0 rows when commit_ts is in the future")

	// Query with guarantee_ts = commit_ts → all rows
	count = s.queryCountWithTs(ctx, collName, tCommit)
	s.Equal(int64(rowNum), count,
		"MVCC: query at commit_ts should return all rows")

	// Query with guarantee_ts > commit_ts → all rows
	count = s.queryCountWithTs(ctx, collName, tAfterCommit)
	s.Equal(int64(rowNum), count,
		"MVCC: query after commit_ts should return all rows")
}

func (s *CommitTimestampSuite) TestMVCC_StrongConsistency_CommitTsInPast() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	const rowNum = 100

	collName, collectionID := s.createCollectionAndInsert(ctx, rowNum)

	// Set commit_ts to now (in the past by the time query runs)
	commitTs := tsoutil.ComposeTSByTime(time.Now())
	s.setCommitTimestamp(collectionID, commitTs)

	s.buildIndexAndLoad(ctx, collName)

	// Strong consistency query should return all rows when commit_ts is in the past
	count := s.queryCountWithTs(ctx, collName, 0)
	s.Equal(int64(rowNum), count,
		"MVCC: strong consistency query should return all rows when commit_ts is in the past")
}

// ─── Search ──────────────────────────────────────────────────────────────

func (s *CommitTimestampSuite) TestSearch_WithGuaranteeTs() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	const rowNum = 100

	collName, collectionID := s.createCollectionAndInsert(ctx, rowNum)

	tBefore := tsoutil.ComposeTSByTime(time.Now())
	tCommit := tsoutil.ComposeTSByTime(time.Now().Add(10 * time.Second))
	s.setCommitTimestamp(collectionID, tCommit)

	s.buildIndexAndLoad(ctx, collName)

	// Search with guarantee_ts < commit_ts → 0 results
	resultCount := s.searchWithTs(ctx, collName, tBefore, 10)
	s.Equal(0, resultCount,
		"search before commit_ts should return 0 results")

	// Search with guarantee_ts = commit_ts → results
	resultCount = s.searchWithTs(ctx, collName, tCommit, 10)
	s.Greater(resultCount, 0,
		"search at commit_ts should return results")
}

// ─── Delete ──────────────────────────────────────────────────────────────

func (s *CommitTimestampSuite) TestDelete_AfterCommitTs() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	const rowNum = 100
	const deleteCount = 10

	collName, collectionID := s.createCollectionAndInsert(ctx, rowNum)

	// commit_ts in the past so delete_ts > commit_ts
	commitTs := tsoutil.ComposeTSByTime(time.Now())
	s.setCommitTimestamp(collectionID, commitTs)

	s.buildIndexAndLoad(ctx, collName)

	// Verify all rows present
	count := s.queryCountWithTs(ctx, collName, 0)
	s.Equal(int64(rowNum), count, "should have all rows before delete")

	s.deleteByPKs(ctx, collName, deleteCount)
	time.Sleep(2 * time.Second)

	// Delete after commit_ts should take effect
	count = s.queryCountWithTs(ctx, collName, 0)
	s.Equal(int64(rowNum-deleteCount), count,
		"delete after commit_ts should take effect")
}

func (s *CommitTimestampSuite) TestDelete_BeforeCommitTs() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	const rowNum = 100
	const deleteCount = 10

	collName, collectionID := s.createCollectionAndInsert(ctx, rowNum)

	// commit_ts in the future so delete_ts < commit_ts
	commitTs := tsoutil.ComposeTSByTime(time.Now().Add(10 * time.Second))
	s.setCommitTimestamp(collectionID, commitTs)

	s.buildIndexAndLoad(ctx, collName)

	s.deleteByPKs(ctx, collName, deleteCount)
	time.Sleep(2 * time.Second)

	// Delete before commit_ts should NOT take effect — query at commit_ts
	count := s.queryCountWithTs(ctx, collName, commitTs)
	s.Equal(int64(rowNum), count,
		"delete before commit_ts should not take effect")
}

// ─── Upsert ──────────────────────────────────────────────────────────────

func (s *CommitTimestampSuite) TestUpsert_AfterCommitTs() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	const rowNum = 100
	const upsertCount = 20

	collName, collectionID := s.createCollectionAndInsert(ctx, rowNum)

	// commit_ts in the past so upsert_ts > commit_ts
	commitTs := tsoutil.ComposeTSByTime(time.Now())
	s.setCommitTimestamp(collectionID, commitTs)

	s.buildIndexAndLoad(ctx, collName)

	// Upsert first 20 rows
	pkColumn := integration.NewInt64FieldDataWithStart(integration.Int64Field, upsertCount, 1)
	vecColumn := integration.NewFloatVectorFieldData(integration.FloatVecField, upsertCount, dim)
	upsertResp, err := s.Cluster.MilvusClient.Upsert(ctx, &milvuspb.UpsertRequest{
		CollectionName: collName,
		FieldsData:     []*schemapb.FieldData{pkColumn, vecColumn},
		NumRows:        uint32(upsertCount),
	})
	s.Require().NoError(err)
	s.Require().True(merr.Ok(upsertResp.GetStatus()))

	time.Sleep(2 * time.Second)

	// After upsert, total count should remain the same (old rows deleted, new rows inserted)
	count := s.queryCountWithTs(ctx, collName, 0)
	s.Equal(int64(rowNum), count,
		"after upsert, total row count should remain %d", rowNum)

	// Validate upsert worked: query the upserted PKs — they should exist
	queryResp, err := s.Cluster.MilvusClient.Query(ctx, &milvuspb.QueryRequest{
		CollectionName:   collName,
		Expr:             fmt.Sprintf("%s in [1,2,3]", integration.Int64Field),
		OutputFields:     []string{integration.Int64Field},
		ConsistencyLevel: commonpb.ConsistencyLevel_Strong,
	})
	s.Require().NoError(err)
	s.Require().True(merr.Ok(queryResp.GetStatus()))
	s.Equal(3, len(queryResp.GetFieldsData()[0].GetScalars().GetLongData().GetData()),
		"upserted PKs should be queryable")
}

func (s *CommitTimestampSuite) TestUpsert_BeforeCommitTs() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	const rowNum = 100
	const upsertCount = 20

	collName, collectionID := s.createCollectionAndInsert(ctx, rowNum)

	// commit_ts in the future so upsert_ts < commit_ts
	commitTs := tsoutil.ComposeTSByTime(time.Now().Add(10 * time.Second))
	s.setCommitTimestamp(collectionID, commitTs)

	s.buildIndexAndLoad(ctx, collName)

	pkColumn := integration.NewInt64FieldDataWithStart(integration.Int64Field, upsertCount, 1)
	vecColumn := integration.NewFloatVectorFieldData(integration.FloatVecField, upsertCount, dim)
	upsertResp, err := s.Cluster.MilvusClient.Upsert(ctx, &milvuspb.UpsertRequest{
		CollectionName: collName,
		FieldsData:     []*schemapb.FieldData{pkColumn, vecColumn},
		NumRows:        uint32(upsertCount),
	})
	s.Require().NoError(err)
	s.Require().True(merr.Ok(upsertResp.GetStatus()))

	time.Sleep(2 * time.Second)

	// Upsert before commit_ts: the delete part should not take effect on the
	// import segment (row didn't exist yet at upsert time), so we should see
	// rowNum + upsertCount rows at commit_ts.
	count := s.queryCountWithTs(ctx, collName, commitTs)
	s.Equal(int64(rowNum+upsertCount), count,
		"upsert before commit_ts: delete part should not apply, expect %d rows", rowNum+upsertCount)
}

// ─── Compaction ──────────────────────────────────────────────────────────

func (s *CommitTimestampSuite) TestCompaction_NormalizesCommitTs() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	const rowsPerSegment = 50

	collName := "CommitTs_Compact_" + funcutil.RandomString(6)

	schema := integration.ConstructSchema(collName, dim, false)
	marshaledSchema, err := proto.Marshal(schema)
	s.Require().NoError(err)

	createResp, err := s.Cluster.MilvusClient.CreateCollection(ctx, &milvuspb.CreateCollectionRequest{
		CollectionName: collName,
		Schema:         marshaledSchema,
		ShardsNum:      1,
	})
	s.Require().NoError(err)
	s.Require().True(merr.Ok(createResp))

	// Insert two batches to create two segments
	for batch := 0; batch < 2; batch++ {
		startPK := int64(batch*rowsPerSegment + 1)
		pkColumn := integration.NewInt64FieldDataWithStart(integration.Int64Field, rowsPerSegment, startPK)
		vecColumn := integration.NewFloatVectorFieldData(integration.FloatVecField, rowsPerSegment, dim)
		insertResp, err := s.Cluster.MilvusClient.Insert(ctx, &milvuspb.InsertRequest{
			CollectionName: collName,
			FieldsData:     []*schemapb.FieldData{pkColumn, vecColumn},
			NumRows:        uint32(rowsPerSegment),
		})
		s.Require().NoError(err)
		s.Require().True(merr.Ok(insertResp.GetStatus()))

		flushResp, err := s.Cluster.MilvusClient.Flush(ctx, &milvuspb.FlushRequest{
			CollectionNames: []string{collName},
		})
		s.Require().NoError(err)
		s.Require().True(merr.Ok(flushResp.GetStatus()))
		segIDs := flushResp.GetCollSegIDs()[collName].GetData()
		flushTs := flushResp.GetCollFlushTs()[collName]
		s.WaitForFlush(ctx, segIDs, flushTs, "", collName)
	}

	showResp, err := s.Cluster.MilvusClient.ShowCollections(ctx, &milvuspb.ShowCollectionsRequest{
		CollectionNames: []string{collName},
	})
	s.Require().NoError(err)
	collectionID := showResp.GetCollectionIds()[0]

	commitTs := tsoutil.ComposeTSByTime(time.Now())
	modifiedSegIDs := s.setCommitTimestamp(collectionID, commitTs)
	s.Require().GreaterOrEqual(len(modifiedSegIDs), 2,
		"should have at least 2 segments to compact")

	// Trigger compaction
	compactResp, err := s.Cluster.MilvusClient.ManualCompaction(ctx, &milvuspb.ManualCompactionRequest{
		CollectionID: collectionID,
	})
	s.Require().NoError(err)
	s.Require().True(merr.Ok(compactResp.GetStatus()))

	compactionID := compactResp.GetCompactionID()

	compactionCompleted := false
	for i := 0; i < 60; i++ {
		time.Sleep(2 * time.Second)
		stateResp, err := s.Cluster.MilvusClient.GetCompactionState(ctx, &milvuspb.GetCompactionStateRequest{
			CompactionID: compactionID,
		})
		if err != nil {
			continue
		}
		if stateResp.GetState() == commonpb.CompactionState_Completed {
			mlog.Info(context.TODO(), "compaction completed", mlog.Int64("compactionID", compactionID))
			compactionCompleted = true
			break
		}
	}
	s.Require().True(compactionCompleted, "compaction did not complete within timeout")

	// Verify output segments: CommitTimestamp = 0, binlog timestamps updated
	segments, err := s.Cluster.ShowSegments(collName)
	s.Require().NoError(err)

	for _, seg := range segments {
		if seg.GetState() == commonpb.SegmentState_Flushed {
			s.Equal(uint64(0), seg.GetCommitTimestamp(),
				"compaction output segment %d must have CommitTimestamp=0 (normalized)", seg.GetID())

			// Verify binlog TimestampFrom/To are reasonable (not stale)
			for _, fieldBinlog := range seg.GetBinlogs() {
				for _, binlog := range fieldBinlog.GetBinlogs() {
					s.GreaterOrEqual(binlog.GetTimestampFrom(), commitTs,
						"compaction output binlog TimestampFrom should be >= commitTs")
					s.GreaterOrEqual(binlog.GetTimestampTo(), commitTs,
						"compaction output binlog TimestampTo should be >= commitTs")
				}
			}
		}
	}

	// Verify data is still queryable after compaction
	s.buildIndexAndLoad(ctx, collName)
	count := s.queryCountWithTs(ctx, collName, 0)
	s.Equal(int64(rowsPerSegment*2), count,
		"all rows should be queryable after compaction normalization")

	// Verify delete works normally after compaction (segment is now normal)
	s.deleteByPKs(ctx, collName, 5)
	time.Sleep(2 * time.Second)
	count = s.queryCountWithTs(ctx, collName, 0)
	s.Equal(int64(rowsPerSegment*2-5), count,
		"delete should work normally on compacted (normalized) segment")
}

// ─── GC Protection ──────────────────────────────────────────────────────

func (s *CommitTimestampSuite) TestGC_ImportSegmentNotPrematurelyDropped() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	const rowNum = 100

	collName, collectionID := s.createCollectionAndInsert(ctx, rowNum)

	// Set commit_ts to now
	commitTs := tsoutil.ComposeTSByTime(time.Now())
	s.setCommitTimestamp(collectionID, commitTs)

	s.buildIndexAndLoad(ctx, collName)

	// Verify data is queryable — if GC prematurely dropped the segment,
	// this query would return 0 rows or fail.
	count := s.queryCountWithTs(ctx, collName, 0)
	s.Equal(int64(rowNum), count,
		"import segment should not be GC'd — all rows should be queryable")

	// Wait a few seconds and query again to ensure stability
	time.Sleep(5 * time.Second)
	count = s.queryCountWithTs(ctx, collName, 0)
	s.Equal(int64(rowNum), count,
		"import segment should remain stable after GC cycles")
}
