// 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 syncmgr

import (
	"context"
	"fmt"
	"reflect"
	"testing"
	"time"

	"github.com/cockroachdb/errors"
	"github.com/minio/minio-go/v7"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"

	"github.com/milvus-io/milvus-proto/go-api/v3/commonpb"
	"github.com/milvus-io/milvus-proto/go-api/v3/schemapb"
	"github.com/milvus-io/milvus/internal/allocator"
	"github.com/milvus-io/milvus/internal/flushcommon/metacache"
	"github.com/milvus-io/milvus/internal/mocks"
	"github.com/milvus-io/milvus/internal/storage"
	"github.com/milvus-io/milvus/pkg/v3/common"
	"github.com/milvus-io/milvus/pkg/v3/proto/datapb"
	"github.com/milvus-io/milvus/pkg/v3/util/merr"
	"github.com/milvus-io/milvus/pkg/v3/util/paramtable"
	"github.com/milvus-io/milvus/pkg/v3/util/retry"
)

func TestBulkPackWriter_Write(t *testing.T) {
	paramtable.Get().Init(paramtable.NewBaseTable())

	seg := metacache.NewSegmentInfo(&datapb.SegmentInfo{}, nil, nil, metacache.NewEmptySegmentStats())
	metacache.UpdateNumOfRows(1000)(seg)
	collectionID := int64(123)
	partitionID := int64(456)
	segmentID := int64(789)
	channelName := fmt.Sprintf("by-dev-rootcoord-dml_0_%dv0", collectionID)
	schema := &schemapb.CollectionSchema{
		Name: "sync_task_test_col",
		Fields: []*schemapb.FieldSchema{
			{FieldID: common.RowIDField, DataType: schemapb.DataType_Int64},
			{FieldID: common.TimeStampField, DataType: schemapb.DataType_Int64},
			{
				FieldID:      100,
				Name:         "pk",
				DataType:     schemapb.DataType_Int64,
				IsPrimaryKey: true,
			},
			{
				FieldID:  101,
				Name:     "vector",
				DataType: schemapb.DataType_FloatVector,
				TypeParams: []*commonpb.KeyValuePair{
					{Key: common.DimKey, Value: "128"},
				},
			},
		},
	}

	mc := metacache.NewMockMetaCache(t)
	mc.EXPECT().Collection().Return(collectionID).Maybe()
	mc.EXPECT().GetSchema(mock.Anything).Return(schema).Maybe()
	mc.EXPECT().GetSegmentByID(segmentID).Return(seg, true).Maybe()
	mc.EXPECT().GetSegmentsBy(mock.Anything, mock.Anything).Return([]*metacache.SegmentInfo{seg}).Maybe()
	mc.EXPECT().UpdateSegments(mock.Anything, mock.Anything).Run(func(action metacache.SegmentAction, filters ...metacache.SegmentFilter) {
		action(seg)
	}).Return().Maybe()

	cm := mocks.NewChunkManager(t)
	cm.EXPECT().RootPath().Return("files").Maybe()
	cm.EXPECT().Write(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()

	deletes := &storage.DeleteData{}
	for i := 0; i < 10; i++ {
		pk := storage.NewInt64PrimaryKey(int64(i + 1))
		ts := uint64(100 + i)
		deletes.Append(pk, ts)
	}

	bw := &BulkPackWriter{
		metaCache:    mc,
		schema:       schema,
		chunkManager: cm,
		allocator:    allocator.NewLocalAllocator(10000, 100000),
	}

	tests := []struct {
		name          string
		pack          *SyncPack
		wantInserts   map[int64]*datapb.FieldBinlog
		wantDeltas    *datapb.FieldBinlog
		wantStats     map[int64]*datapb.FieldBinlog
		wantBm25Stats map[int64]*datapb.FieldBinlog
		wantSize      int64
		wantErr       error
	}{
		{
			name:          "empty",
			pack:          new(SyncPack).WithCollectionID(collectionID).WithPartitionID(partitionID).WithSegmentID(segmentID).WithChannelName(channelName),
			wantInserts:   map[int64]*datapb.FieldBinlog{},
			wantDeltas:    &datapb.FieldBinlog{},
			wantStats:     map[int64]*datapb.FieldBinlog{},
			wantBm25Stats: map[int64]*datapb.FieldBinlog{},
			wantSize:      0,
			wantErr:       nil,
		},
		{
			name:        "with delete",
			pack:        new(SyncPack).WithCollectionID(collectionID).WithPartitionID(partitionID).WithSegmentID(segmentID).WithChannelName(channelName).WithDeleteData(deletes),
			wantInserts: map[int64]*datapb.FieldBinlog{},
			wantDeltas: &datapb.FieldBinlog{
				FieldID: 100,
				Binlogs: []*datapb.Binlog{
					{
						EntriesNum:    10,
						TimestampFrom: 100,
						TimestampTo:   109,
						LogPath:       "files/delta_log/123/456/789/10000",
						LogSize:       60,
						MemorySize:    240,
					},
				},
			},
			wantStats:     map[int64]*datapb.FieldBinlog{},
			wantBm25Stats: map[int64]*datapb.FieldBinlog{},
			wantSize:      60,
			wantErr:       nil,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			gotInserts, gotDeltas, gotStats, gotBm25Stats, gotSize, err := bw.Write(context.Background(), tt.pack)
			if err != tt.wantErr {
				t.Errorf("BulkPackWriter.Write() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if !reflect.DeepEqual(gotInserts, tt.wantInserts) {
				t.Errorf("BulkPackWriter.Write() gotInserts = %v, want %v", gotInserts, tt.wantInserts)
			}
			if !reflect.DeepEqual(gotDeltas, tt.wantDeltas) {
				t.Errorf("BulkPackWriter.Write() gotDeltas = %v, want %v", gotDeltas, tt.wantDeltas)
			}
			if !reflect.DeepEqual(gotStats, tt.wantStats) {
				t.Errorf("BulkPackWriter.Write() gotStats = %v, want %v", gotStats, tt.wantStats)
			}
			if !reflect.DeepEqual(gotBm25Stats, tt.wantBm25Stats) {
				t.Errorf("BulkPackWriter.Write() gotBm25Stats = %v, want %v", gotBm25Stats, tt.wantBm25Stats)
			}
			if gotSize != tt.wantSize {
				t.Errorf("BulkPackWriter.Write() gotSize = %v, want %v", gotSize, tt.wantSize)
			}
		})
	}
}

func TestBulkPackWriter_WriteDelta_RetryTransientWriteFailure(t *testing.T) {
	paramtable.Get().Init(paramtable.NewBaseTable())

	collectionID := int64(123)
	partitionID := int64(456)
	segmentID := int64(789)
	schema := &schemapb.CollectionSchema{
		Name: "sync_task_test_col",
		Fields: []*schemapb.FieldSchema{
			{FieldID: common.RowIDField, DataType: schemapb.DataType_Int64},
			{FieldID: common.TimeStampField, DataType: schemapb.DataType_Int64},
			{FieldID: 100, Name: "pk", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
		},
	}

	cm := mocks.NewChunkManager(t)
	cm.EXPECT().RootPath().Return("files").Maybe()
	callCount := 0
	cm.EXPECT().Write(mock.Anything, mock.Anything, mock.Anything).
		RunAndReturn(func(ctx context.Context, key string, data []byte) error {
			callCount++
			if callCount == 1 {
				return errors.New("transient object storage timeout")
			}
			return nil
		})

	deletes := &storage.DeleteData{}
	for i := 0; i < 10; i++ {
		pk := storage.NewInt64PrimaryKey(int64(i + 1))
		ts := uint64(100 + i)
		deletes.Append(pk, ts)
	}

	bw := &BulkPackWriter{
		schema:         schema,
		chunkManager:   cm,
		allocator:      allocator.NewLocalAllocator(10000, 100000),
		writeRetryOpts: []retry.Option{retry.AttemptAlways(), retry.Sleep(time.Millisecond), retry.MaxSleepTime(time.Millisecond)},
	}

	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	got, err := bw.writeDelta(ctx, new(SyncPack).
		WithCollectionID(collectionID).
		WithPartitionID(partitionID).
		WithSegmentID(segmentID).
		WithDeleteData(deletes))

	require.NoError(t, err)
	require.Equal(t, 2, callCount)
	require.Equal(t, int64(10), got.GetBinlogs()[0].GetEntriesNum())
}

func TestValidateStorageV1InsertWritableSchema(t *testing.T) {
	arrayOfVectorField := func(nullable bool) *schemapb.FieldSchema {
		return &schemapb.FieldSchema{
			FieldID:     101,
			Name:        "array_of_vector",
			DataType:    schemapb.DataType_ArrayOfVector,
			ElementType: schemapb.DataType_FloatVector,
			Nullable:    nullable,
			TypeParams: []*commonpb.KeyValuePair{
				{Key: common.DimKey, Value: "128"},
			},
		}
	}
	arrayField := func(nullable bool) *schemapb.FieldSchema {
		return &schemapb.FieldSchema{
			FieldID:     102,
			Name:        "array",
			DataType:    schemapb.DataType_Array,
			ElementType: schemapb.DataType_Int64,
			Nullable:    nullable,
		}
	}

	tests := []struct {
		name      string
		schema    *schemapb.CollectionSchema
		wantError bool
	}{
		{
			name: "top level nullable array of vector",
			schema: &schemapb.CollectionSchema{
				Fields: []*schemapb.FieldSchema{arrayOfVectorField(true)},
			},
			wantError: true,
		},
		{
			name: "top level non-nullable array of vector",
			schema: &schemapb.CollectionSchema{
				Fields: []*schemapb.FieldSchema{arrayOfVectorField(false)},
			},
		},
		{
			name: "nullable struct with nullable array of vector sub-field",
			schema: &schemapb.CollectionSchema{
				StructArrayFields: []*schemapb.StructArrayFieldSchema{
					{
						Name:     "struct_array",
						Nullable: true,
						Fields:   []*schemapb.FieldSchema{arrayOfVectorField(true)},
					},
				},
			},
			wantError: true,
		},
		{
			name: "nullable struct with normalized non-nullable array of vector sub-field",
			schema: &schemapb.CollectionSchema{
				StructArrayFields: []*schemapb.StructArrayFieldSchema{
					{
						Name:     "struct_array",
						Nullable: true,
						Fields:   []*schemapb.FieldSchema{arrayOfVectorField(false)},
					},
				},
			},
		},
		{
			name: "non-nullable struct with nullable array of vector sub-field",
			schema: &schemapb.CollectionSchema{
				StructArrayFields: []*schemapb.StructArrayFieldSchema{
					{
						Name:   "struct_array",
						Fields: []*schemapb.FieldSchema{arrayOfVectorField(true)},
					},
				},
			},
			wantError: true,
		},
		{
			name: "nullable struct with array sub-field",
			schema: &schemapb.CollectionSchema{
				StructArrayFields: []*schemapb.StructArrayFieldSchema{
					{
						Name:     "struct_array",
						Nullable: true,
						Fields:   []*schemapb.FieldSchema{arrayField(false)},
					},
				},
			},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			err := storage.ValidateStorageV1InsertWritableSchema(test.schema)
			if test.wantError {
				require.Error(t, err)
				assert.Contains(t, err.Error(), "nullable ArrayOfVector is not supported in V1 storage format")
				return
			}
			require.NoError(t, err)
		})
	}
}

func TestBulkPackWriter_WriteLog_NonRetryableError(t *testing.T) {
	paramtable.Get().Init(paramtable.NewBaseTable())

	mc := metacache.NewMockMetaCache(t)
	mc.EXPECT().Collection().Return(int64(1)).Maybe()

	cm := mocks.NewChunkManager(t)
	cm.EXPECT().RootPath().Return("files").Maybe()
	// Return a permission-denied error — should NOT be retried
	callCount := 0
	cm.EXPECT().Write(mock.Anything, mock.Anything, mock.Anything).
		RunAndReturn(func(ctx context.Context, key string, data []byte) error {
			callCount++
			// Simulate MinIO AccessDenied — maps to ErrIoPermissionDenied via ToMilvusIoError
			return minio.ErrorResponse{Code: "AccessDenied"}
		})

	schema := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: common.RowIDField, DataType: schemapb.DataType_Int64},
			{FieldID: common.TimeStampField, DataType: schemapb.DataType_Int64},
			{FieldID: 100, Name: "pk", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
			{
				FieldID: 101, Name: "vec", DataType: schemapb.DataType_FloatVector,
				TypeParams: []*commonpb.KeyValuePair{{Key: common.DimKey, Value: "4"}},
			},
		},
	}

	bw := &BulkPackWriter{
		metaCache:      mc,
		schema:         schema,
		chunkManager:   cm,
		allocator:      allocator.NewLocalAllocator(10000, 100000),
		writeRetryOpts: []retry.Option{retry.AttemptAlways(), retry.MaxSleepTime(10 * time.Second)},
	}

	// Use a timeout context so the test doesn't hang if retry loop is infinite
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	blob := &storage.Blob{Value: []byte("data"), RowNum: 1}
	_, err := bw.writeLog(ctx, blob, "insert_log", "1/2/3/100/1", nil)
	require.Error(t, err)
	// Must stop after exactly 1 attempt — not retried
	assert.Equal(t, 1, callCount, "non-retryable error should not be retried")
	assert.True(t, merr.IsNonRetryableErr(err))
	assert.True(t, errors.Is(err, merr.ErrIoPermissionDenied))
}
