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

import (
	"context"
	"fmt"
	"io"
	"sync/atomic"
	"testing"

	"github.com/apache/arrow/go/v17/arrow"
	"github.com/apache/arrow/go/v17/arrow/array"
	"github.com/apache/arrow/go/v17/arrow/memory"
	"github.com/bytedance/mockey"
	"github.com/stretchr/testify/suite"

	"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/storage"
	"github.com/milvus-io/milvus/internal/storagecommon"
	"github.com/milvus-io/milvus/internal/storagev2/packed"
	"github.com/milvus-io/milvus/internal/util/function/embedding"
	"github.com/milvus-io/milvus/internal/util/hookutil"
	"github.com/milvus-io/milvus/pkg/v3/common"
	"github.com/milvus-io/milvus/pkg/v3/proto/datapb"
	"github.com/milvus-io/milvus/pkg/v3/proto/indexcgopb"
	"github.com/milvus-io/milvus/pkg/v3/proto/indexpb"
	"github.com/milvus-io/milvus/pkg/v3/util/externalspec"
	"github.com/milvus-io/milvus/pkg/v3/util/paramtable"
	"github.com/milvus-io/milvus/pkg/v3/util/typeutil"
)

type RefreshExternalCollectionTaskSuite struct {
	suite.Suite
	collectionID int64
	taskID       int64
}

func (s *RefreshExternalCollectionTaskSuite) newTask(
	ctx context.Context,
	req *datapb.RefreshExternalCollectionTaskRequest,
) *RefreshExternalCollectionTask {
	if req != nil && req.GetTargetRowsPerSegment() == 0 {
		req.TargetRowsPerSegment = 1_000_000
	}
	return NewRefreshExternalCollectionTask(ctx, req)
}

type fakeRecordReader struct {
	records []storage.Record
	errs    []error
	idx     int
}

func (r *fakeRecordReader) Next() (storage.Record, error) {
	if r.idx < len(r.errs) && r.errs[r.idx] != nil {
		err := r.errs[r.idx]
		r.idx++
		return nil, err
	}
	if r.idx >= len(r.records) {
		return nil, io.EOF
	}
	record := r.records[r.idx]
	r.idx++
	return record, nil
}

func (r *fakeRecordReader) Close() error {
	return nil
}

func (s *RefreshExternalCollectionTaskSuite) SetupSuite() {
	s.collectionID = 1000
	s.taskID = 1
}

func (s *RefreshExternalCollectionTaskSuite) TestNewRefreshExternalCollectionTask() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:   s.collectionID,
		TaskID:         s.taskID,
		ExternalSource: "test_source",
		ExternalSpec:   "test_spec",
	}

	task := s.newTask(ctx, req)

	s.NotNil(task)
	s.Equal(s.collectionID, task.req.GetCollectionID())
	s.Equal(s.taskID, task.req.GetTaskID())
	s.Equal(indexpb.JobState_JobStateInit, task.GetState())
	s.Contains(task.Name(), "RefreshExternalCollectionTask")
}

func (s *RefreshExternalCollectionTaskSuite) TestTaskLifecycle() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:   s.collectionID,
		TaskID:         s.taskID,
		ExternalSource: "test_source",
		ExternalSpec:   `{"format":"parquet"}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{Name: "id", ExternalField: "id"},
			},
		},
		StorageConfig: &indexpb.StorageConfig{
			StorageType: "local",
		},
		CurrentSegments: []*datapb.SegmentInfo{
			{ID: 1, CollectionID: s.collectionID, NumOfRows: 1000},
			{ID: 2, CollectionID: s.collectionID, NumOfRows: 2000},
		},
		PreAllocatedSegmentIds: &datapb.IDRange{
			Begin: 1000,
			End:   2000,
		},
	}

	task := s.newTask(ctx, req)

	// Test OnEnqueue
	err := task.OnEnqueue(ctx)
	s.NoError(err)

	// Test PreExecute - validates schema, storage config, external source
	err = task.PreExecute(ctx)
	s.NoError(err)
	s.NotNil(task.parsedSpec)
	s.Equal("parquet", task.parsedSpec.Format)
	s.Equal([]string{"id"}, task.columns)

	// Test PostExecute (Execute skipped as it requires CGO FFI calls)
	err = task.PostExecute(ctx)
	s.NoError(err)

	// Test GetSlot
	s.Equal(int64(1), task.GetSlot())
}

func (s *RefreshExternalCollectionTaskSuite) TestPreExecuteClonesSchemaBeforeFillingExternalMetadata() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	sourceSchema := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{Name: "id", ExternalField: "id"},
		},
	}
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:   s.collectionID,
		TaskID:         s.taskID,
		ExternalSource: "s3://bucket/data/",
		ExternalSpec:   `{"format":"parquet"}`,
		Schema:         sourceSchema,
		StorageConfig: &indexpb.StorageConfig{
			StorageType: "local",
		},
	}

	task := s.newTask(ctx, req)
	err := task.PreExecute(ctx)

	s.NoError(err)
	s.Empty(sourceSchema.GetExternalSource())
	s.Empty(sourceSchema.GetExternalSpec())
	s.NotSame(sourceSchema, task.req.GetSchema())
	s.Equal(req.GetExternalSource(), task.req.GetSchema().GetExternalSource())
	s.Equal(req.GetExternalSpec(), task.req.GetSchema().GetExternalSpec())
	s.Equal([]string{"id"}, task.columns)
}

func (s *RefreshExternalCollectionTaskSuite) TestPreExecuteFallsBackToConfigWhenTargetRowsPerSegmentMissing() {
	paramtable.Init()
	const targetRowsPerSegmentKey = "dataNode.externalCollection.targetRowsPerSegment"
	paramtable.Get().Save(targetRowsPerSegmentKey, "12345")
	defer paramtable.Get().Reset(targetRowsPerSegmentKey)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:   s.collectionID,
		TaskID:         s.taskID,
		ExternalSource: "test_source",
		ExternalSpec:   `{"format":"parquet"}`,
		Schema:         &schemapb.CollectionSchema{},
		StorageConfig:  &indexpb.StorageConfig{StorageType: "local"},
	}

	task := NewRefreshExternalCollectionTask(ctx, req)
	err := task.PreExecute(ctx)
	s.NoError(err)
	s.Equal(int64(12345), req.GetTargetRowsPerSegment())
}

func (s *RefreshExternalCollectionTaskSuite) TestPreExecuteRejectsNegativeTargetRowsPerSegment() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:         s.collectionID,
		TaskID:               s.taskID,
		ExternalSource:       "test_source",
		ExternalSpec:         `{"format":"parquet"}`,
		Schema:               &schemapb.CollectionSchema{},
		StorageConfig:        &indexpb.StorageConfig{StorageType: "local"},
		TargetRowsPerSegment: -1,
	}

	task := NewRefreshExternalCollectionTask(ctx, req)
	err := task.PreExecute(ctx)
	s.Error(err)
	s.Contains(err.Error(), "target rows per segment must be positive")
}

func (s *RefreshExternalCollectionTaskSuite) TestPreExecuteWithNilRequest() {
	ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // cancel is deferred below
	defer cancel()
	task := &RefreshExternalCollectionTask{
		ctx: ctx,
		req: nil,
	}

	err := task.PreExecute(ctx)
	s.Error(err)
}

func (s *RefreshExternalCollectionTaskSuite) TestSetAndGetState() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID: s.collectionID,
		TaskID:       s.taskID,
	}

	task := s.newTask(ctx, req)

	task.SetState(indexpb.JobState_JobStateInProgress, "")
	s.Equal(indexpb.JobState_JobStateInProgress, task.GetState())

	task.SetState(indexpb.JobState_JobStateFailed, "test failure")
	s.Equal(indexpb.JobState_JobStateFailed, task.GetState())
	s.Equal("test failure", task.failReason)
}

func (s *RefreshExternalCollectionTaskSuite) TestReset() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID: s.collectionID,
		TaskID:       s.taskID,
	}

	task := s.newTask(ctx, req)
	task.Reset()

	s.Nil(task.ctx)
	s.Nil(task.req)
	s.Nil(task.tr)
	s.Nil(task.updatedSegments)
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_Empty() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID: s.collectionID,
		TaskID:       s.taskID,
	}

	task := s.newTask(ctx, req)
	result, err := task.balanceFragmentsToSegments(context.Background(), []packed.Fragment{})
	s.NoError(err)
	s.Nil(result)
}

// Regression for #49225: zero-row parquet produces fragments whose RowCount
// sums to 0 and previously triggered divide-by-zero panic in
// balanceFragmentsToSegments. Must return error, never panic.
func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_ZeroTotalRowsWithFragments() {
	paramtable.Init()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID: s.collectionID,
		TaskID:       s.taskID,
	}
	task := s.newTask(ctx, req)

	fragments := []packed.Fragment{
		{FragmentID: 1, RowCount: 0, FilePath: "s3://bucket/zero.parquet"},
		{FragmentID: 2, RowCount: 0, FilePath: "s3://bucket/zero2.parquet"},
	}
	s.NotPanics(func() {
		result, err := task.balanceFragmentsToSegments(context.Background(), fragments)
		s.Error(err)
		s.Nil(result)
		s.Contains(err.Error(), "zero total rows")
	})
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_SingleFragment() {
	paramtable.Init()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", ExternalField: "text_col"},
				{FieldID: 101, Name: "vec", ExternalField: "vec_col"},
			},
		},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	m1 := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(ctx context.Context, basePath, format string, columns []string, fragments []packed.Fragment, storageConfig *indexpb.StorageConfig, extfs packed.ExternalSpecContext) (string, error) {
			return fmt.Sprintf("%s/manifest.json", basePath), nil
		}).Build()
	defer m1.UnPatch()

	m2 := mockey.Mock(packed.SampleExternalFieldSizes).
		Return(map[string]int64{"text_col": 64, "vec_col": 512}, nil).Build()
	defer m2.UnPatch()

	fragments := []packed.Fragment{
		{FragmentID: 1, RowCount: 500},
	}

	result, err := task.balanceFragmentsToSegments(context.Background(), fragments)
	s.NoError(err)
	s.Len(result, 1)
	s.Equal(int64(500), result[0].GetNumOfRows())
	s.Equal(storage.StorageV3, result[0].GetStorageVersion(),
		"external segments should have StorageVersion=V3")

	// Verify fake binlogs
	s.Len(result[0].GetBinlogs(), 1)
	fb := result[0].GetBinlogs()[0]
	s.Equal(int64(0), fb.GetFieldID(), "should use DefaultShortColumnGroupID")
	s.ElementsMatch([]int64{100, 101}, fb.GetChildFields())
	s.Len(fb.GetBinlogs(), 1)
	binlog := fb.GetBinlogs()[0]
	s.Equal(int64(500), binlog.GetEntriesNum())
	// avgBytesPerRow = 64 + 512 = 576, memorySize = 576 * 500 = 288000
	s.Equal(int64(288000), binlog.GetMemorySize())
	s.Equal(int64(288000), binlog.GetLogSize())
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_MultipleFragments() {
	paramtable.Init()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		TargetRowsPerSegment:   500000,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", ExternalField: "text_col"},
			},
		},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	m1 := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(ctx context.Context, basePath, format string, columns []string, fragments []packed.Fragment, storageConfig *indexpb.StorageConfig, extfs packed.ExternalSpecContext) (string, error) {
			return fmt.Sprintf("%s/manifest.json", basePath), nil
		}).Build()
	defer m1.UnPatch()

	m2 := mockey.Mock(packed.SampleExternalFieldSizes).
		Return(map[string]int64{"text_col": 100}, nil).Build()
	defer m2.UnPatch()

	fragments := []packed.Fragment{
		{FragmentID: 1, RowCount: 300000},
		{FragmentID: 2, RowCount: 400000},
		{FragmentID: 3, RowCount: 500000},
		{FragmentID: 4, RowCount: 600000},
		{FragmentID: 5, RowCount: 200000},
	}

	result, err := task.balanceFragmentsToSegments(context.Background(), fragments)
	s.NoError(err)
	s.Len(result, 4)

	// Verify all segments have StorageVersion=V3 and fake binlogs
	for i, seg := range result {
		s.Equal(storage.StorageV3, seg.GetStorageVersion(),
			"segment %d should have StorageVersion=V3", i)
		s.NotEmpty(seg.GetBinlogs(), "segment %d should have fake binlogs", i)
		fb := seg.GetBinlogs()[0]
		s.Equal(int64(0), fb.GetFieldID())
		s.Equal([]int64{100}, fb.GetChildFields())
		// MemorySize = avgBytesPerRow(100) * numRows
		s.Equal(int64(100)*seg.GetNumOfRows(), fb.GetBinlogs()[0].GetMemorySize())
	}

	// Verify total rows are preserved
	var totalRows int64
	for _, seg := range result {
		totalRows += seg.GetNumOfRows()
	}
	s.Equal(int64(2000000), totalRows)

	// Verify segments are reasonably balanced
	if len(result) > 1 {
		minRows, maxRows := result[0].GetNumOfRows(), result[0].GetNumOfRows()
		for _, seg := range result {
			if seg.GetNumOfRows() < minRows {
				minRows = seg.GetNumOfRows()
			}
			if seg.GetNumOfRows() > maxRows {
				maxRows = seg.GetNumOfRows()
			}
		}
		// The difference between max and min should be reasonable
		avgFragmentSize := int64(2000000 / 5)
		s.Less(maxRows-minRows, avgFragmentSize*2)
	}
}

func (s *RefreshExternalCollectionTaskSuite) TestPreExecuteContextCanceled() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID: s.collectionID,
		TaskID:       s.taskID,
	}

	task := s.newTask(ctx, req)
	cancel()

	err := task.PreExecute(ctx)
	s.ErrorIs(err, context.Canceled)
}

func (s *RefreshExternalCollectionTaskSuite) TestExecuteContextCanceled() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID: s.collectionID,
		TaskID:       s.taskID,
	}

	task := s.newTask(ctx, req)
	cancel()

	err := task.Execute(ctx)
	s.ErrorIs(err, context.Canceled)
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegmentsContextCanceled() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID: s.collectionID,
		TaskID:       s.taskID,
	}

	task := s.newTask(ctx, req)
	cancel()

	result, err := task.balanceFragmentsToSegments(ctx, []packed.Fragment{{FragmentID: 1, RowCount: 10}})
	s.ErrorIs(err, context.Canceled)
	s.Nil(result)
}

func (s *RefreshExternalCollectionTaskSuite) TestOrganizeSegments_AllFragmentsExist() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID: s.collectionID,
		TaskID:       s.taskID,
		CurrentSegments: []*datapb.SegmentInfo{
			{ID: 1, CollectionID: s.collectionID, NumOfRows: 1000},
			{ID: 2, CollectionID: s.collectionID, NumOfRows: 2000},
		},
	}

	task := s.newTask(ctx, req)

	// Simulate current segment fragments mapping (use FilePath as identifier)
	currentSegmentFragments := packed.SegmentFragments{
		1: []packed.Fragment{{FragmentID: 101, FilePath: "/data/file1.parquet", RowCount: 1000}},
		2: []packed.Fragment{{FragmentID: 102, FilePath: "/data/file2.parquet", RowCount: 2000}},
	}

	// New fragments contain all existing fragments
	newFragments := []packed.Fragment{
		{FragmentID: 101, FilePath: "/data/file1.parquet", RowCount: 1000},
		{FragmentID: 102, FilePath: "/data/file2.parquet", RowCount: 2000},
	}

	result, err := task.organizeSegments(context.Background(), currentSegmentFragments, newFragments)
	s.NoError(err)

	// Both segments should be kept
	s.Len(result, 2)

	// Verify kept/new tracking
	s.ElementsMatch([]int64{1, 2}, task.GetKeptSegmentIDs())
	s.Empty(task.GetUpdatedSegments())
}

func (s *RefreshExternalCollectionTaskSuite) TestOrganizeSegments_SameFragmentsMissingFieldPatchesSegment() {
	paramtable.Init()

	ctx := context.Background()
	partitionID := int64(2000)
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:   s.collectionID,
		PartitionID:    partitionID,
		TaskID:         s.taskID,
		ExternalSpec:   `{"format":"parquet"}`,
		ExternalSource: "s3://bucket/data/",
		StorageConfig:  &indexpb.StorageConfig{StorageType: "local"},
		Schema: &schemapb.CollectionSchema{
			Version: 4,
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "id", ExternalField: "id"},
				{FieldID: 101, Name: "vec", ExternalField: "vec"},
				{FieldID: 102, Name: "text", ExternalField: "text"},
				{FieldID: 103, Name: "score", ExternalField: "score"},
			},
		},
		CurrentSegments: []*datapb.SegmentInfo{{
			ID:             10,
			CollectionID:   s.collectionID,
			PartitionID:    partitionID,
			NumOfRows:      1000,
			ManifestPath:   `{"base_path":"seg10","ver":1}`,
			StorageVersion: storage.StorageV3,
			SchemaVersion:  3,
			Binlogs: buildFakeBinlogs(10, 1000, 3000, &schemapb.CollectionSchema{
				Fields: []*schemapb.FieldSchema{
					{FieldID: 100, Name: "id", ExternalField: "id"},
					{FieldID: 101, Name: "vec", ExternalField: "vec"},
					{FieldID: 102, Name: "text", ExternalField: "text"},
				},
			}, "parquet"),
		}},
	}
	task := s.newTask(ctx, req)
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}
	task.columns = []string{"id", "vec", "text", "score"}

	mockAppend := mockey.Mock(packed.AppendSegmentManifestColumns).
		To(func(ctx context.Context, oldManifestPath string, format string, columns []string, fragments []packed.Fragment, storageConfig *indexpb.StorageConfig) (string, error) {
			s.Equal(`{"base_path":"seg10","ver":1}`, oldManifestPath)
			s.Equal([]string{"score"}, columns)
			return `{"base_path":"seg10","ver":2}`, nil
		}).Build()
	defer mockAppend.UnPatch()

	mockSample := mockey.Mock(packed.SampleExternalFieldSizes).
		To(func(manifestPath string, sampleRows int, collectionID int64, externalSource string, externalSpec string, schema *schemapb.CollectionSchema, storageConfig *indexpb.StorageConfig) (map[string]int64, error) {
			s.Equal(`{"base_path":"seg10","ver":2}`, manifestPath)
			s.Equal(100, sampleRows)
			s.Equal(s.collectionID, collectionID)
			s.Equal("s3://bucket/data/", externalSource)
			s.Equal(`{"format":"parquet"}`, externalSpec)
			s.Equal(req.GetSchema(), schema)
			s.Equal(req.GetStorageConfig(), storageConfig)
			return map[string]int64{"id": 8, "vec": 128, "text": 32, "score": 8}, nil
		}).Build()
	defer mockSample.UnPatch()

	current := packed.SegmentFragments{
		10: []packed.Fragment{{FilePath: "s3://bucket/data/a.parquet", StartRow: 0, EndRow: 1000, RowCount: 1000}},
	}
	next := []packed.Fragment{{FilePath: "s3://bucket/data/a.parquet", StartRow: 0, EndRow: 1000, RowCount: 1000}}

	result, err := task.organizeSegments(ctx, current, next)
	s.NoError(err)
	s.Len(result, 1)
	s.Empty(task.GetKeptSegmentIDs())
	s.Len(task.GetUpdatedSegments(), 1)
	patched := task.GetUpdatedSegments()[0]
	s.Equal(int64(10), patched.GetID())
	s.Equal(`{"base_path":"seg10","ver":2}`, patched.GetManifestPath())
	s.Equal(int32(4), patched.GetSchemaVersion())
	s.ElementsMatch([]int64{100, 101, 102, 103}, patched.GetBinlogs()[0].GetChildFields())

	result, err = task.organizeSegments(ctx, current, next)
	s.NoError(err)
	s.Len(result, 1)
	s.Empty(task.GetKeptSegmentIDs())
	s.Len(task.GetUpdatedSegments(), 1)
	s.Equal(int64(10), task.GetUpdatedSegments()[0].GetID())
}

func (s *RefreshExternalCollectionTaskSuite) TestOrganizeSegments_PatchedSegmentCountsFunctionOutputMemory() {
	paramtable.Init()

	ctx := context.Background()
	partitionID := int64(2000)
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:   s.collectionID,
		PartitionID:    partitionID,
		TaskID:         s.taskID,
		ExternalSpec:   `{"format":"parquet"}`,
		ExternalSource: "s3://bucket/data/",
		StorageConfig:  &indexpb.StorageConfig{StorageType: "local"},
		Schema: &schemapb.CollectionSchema{
			Version: 4,
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "id", DataType: schemapb.DataType_Int64, ExternalField: "id"},
				{FieldID: 101, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text"},
				{FieldID: 102, Name: "score", DataType: schemapb.DataType_Double, ExternalField: "score"},
				{
					FieldID:          103,
					Name:             "embedding",
					DataType:         schemapb.DataType_FloatVector,
					IsFunctionOutput: true,
					TypeParams:       []*commonpb.KeyValuePair{{Key: common.DimKey, Value: "4"}},
				},
			},
		},
		CurrentSegments: []*datapb.SegmentInfo{{
			ID:             10,
			CollectionID:   s.collectionID,
			PartitionID:    partitionID,
			NumOfRows:      100,
			ManifestPath:   `{"base_path":"seg10","ver":1}`,
			StorageVersion: storage.StorageV3,
			SchemaVersion:  3,
			Binlogs: buildFakeBinlogs(10, 100, 4800, &schemapb.CollectionSchema{
				Fields: []*schemapb.FieldSchema{
					{FieldID: 100, Name: "id", DataType: schemapb.DataType_Int64, ExternalField: "id"},
					{FieldID: 101, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text"},
					{
						FieldID:          103,
						Name:             "embedding",
						DataType:         schemapb.DataType_FloatVector,
						IsFunctionOutput: true,
						TypeParams:       []*commonpb.KeyValuePair{{Key: common.DimKey, Value: "4"}},
					},
				},
			}, "parquet"),
		}},
	}
	task := s.newTask(ctx, req)
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}
	task.columns = []string{"id", "text", "score"}

	mockAppend := mockey.Mock(packed.AppendSegmentManifestColumns).
		To(func(ctx context.Context, oldManifestPath string, format string, columns []string, fragments []packed.Fragment, storageConfig *indexpb.StorageConfig) (string, error) {
			s.Equal(`{"base_path":"seg10","ver":1}`, oldManifestPath)
			s.Equal([]string{"score"}, columns)
			return `{"base_path":"seg10","ver":2}`, nil
		}).Build()
	defer mockAppend.UnPatch()

	mockSample := mockey.Mock(packed.SampleExternalFieldSizes).
		Return(map[string]int64{"id": 8, "text": 32, "score": 8}, nil).Build()
	defer mockSample.UnPatch()

	current := packed.SegmentFragments{
		10: []packed.Fragment{{FilePath: "s3://bucket/data/a.parquet", StartRow: 0, EndRow: 100, RowCount: 100}},
	}
	next := []packed.Fragment{{FilePath: "s3://bucket/data/a.parquet", StartRow: 0, EndRow: 100, RowCount: 100}}

	result, err := task.organizeSegments(ctx, current, next)
	s.NoError(err)
	s.Len(result, 1)
	patched := task.GetUpdatedSegments()[0]
	s.ElementsMatch([]int64{100, 101, 102, 103}, patched.GetBinlogs()[0].GetChildFields())
	s.Equal(int64((8+32+8+16)*100), patched.GetBinlogs()[0].GetBinlogs()[0].GetMemorySize())
}

func (s *RefreshExternalCollectionTaskSuite) TestOrganizeSegments_SameFragmentsAllFieldsCoveredKeepsSegment() {
	ctx := context.Background()
	partitionID := int64(2000)
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:  s.collectionID,
		PartitionID:   partitionID,
		TaskID:        s.taskID,
		ExternalSpec:  `{"format":"parquet"}`,
		StorageConfig: &indexpb.StorageConfig{StorageType: "local"},
		Schema: &schemapb.CollectionSchema{
			Version: 4,
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "id", ExternalField: "id"},
				{FieldID: 101, Name: "vec", ExternalField: "vec"},
			},
		},
		CurrentSegments: []*datapb.SegmentInfo{{
			ID:             10,
			CollectionID:   s.collectionID,
			PartitionID:    partitionID,
			NumOfRows:      100,
			ManifestPath:   `{"base_path":"seg10","ver":1}`,
			StorageVersion: storage.StorageV3,
			SchemaVersion:  4,
			Binlogs: buildFakeBinlogs(10, 100, 3000, &schemapb.CollectionSchema{
				Fields: []*schemapb.FieldSchema{
					{FieldID: 100, Name: "id", ExternalField: "id"},
					{FieldID: 101, Name: "vec", ExternalField: "vec"},
				},
			}, "parquet"),
		}},
	}
	task := s.newTask(ctx, req)
	current := packed.SegmentFragments{
		10: []packed.Fragment{{FilePath: "s3://bucket/data/a.parquet", StartRow: 0, EndRow: 100, RowCount: 100}},
	}
	next := []packed.Fragment{{FilePath: "s3://bucket/data/a.parquet", StartRow: 0, EndRow: 100, RowCount: 100}}

	result, err := task.organizeSegments(ctx, current, next)
	s.NoError(err)
	s.Len(result, 1)
	s.ElementsMatch([]int64{10}, task.GetKeptSegmentIDs())
	s.Empty(task.GetUpdatedSegments())
}

func (s *RefreshExternalCollectionTaskSuite) TestOrganizeSegments_FragmentRemoved() {
	s.T().Skip("Skip test that requires CGO FFI calls for manifest creation")
}

func (s *RefreshExternalCollectionTaskSuite) TestOrganizeSegments_PartialFragmentRemoved() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID: s.collectionID,
		TaskID:       s.taskID,
		CurrentSegments: []*datapb.SegmentInfo{
			{ID: 1, CollectionID: s.collectionID, NumOfRows: 1000},
			{ID: 2, CollectionID: s.collectionID, NumOfRows: 2000},
			{ID: 3, CollectionID: s.collectionID, NumOfRows: 1500},
		},
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
	}

	task := s.newTask(ctx, req)

	// S1 has file1, S2 has file2, S3 has file3
	currentSegmentFragments := packed.SegmentFragments{
		1: []packed.Fragment{{FragmentID: 101, FilePath: "/data/file1.parquet", RowCount: 1000}},
		2: []packed.Fragment{{FragmentID: 102, FilePath: "/data/file2.parquet", RowCount: 2000}},
		3: []packed.Fragment{{FragmentID: 103, FilePath: "/data/file3.parquet", RowCount: 1500}},
	}

	// New fragments: file1 and file3 still exist, file2 removed
	newFragments := []packed.Fragment{
		{FragmentID: 201, FilePath: "/data/file1.parquet", RowCount: 1000},
		{FragmentID: 203, FilePath: "/data/file3.parquet", RowCount: 1500},
	}

	result, err := task.organizeSegments(context.Background(), currentSegmentFragments, newFragments)
	s.NoError(err)

	// S1 and S3 should be kept, S2 should be invalidated
	// No new segments because there are no orphan fragments (all new fragments match kept segments)
	s.Len(result, 2)
	s.ElementsMatch([]int64{1, 3}, task.GetKeptSegmentIDs())
	s.Empty(task.GetUpdatedSegments(), "No orphan fragments to create new segments from")
}

func (s *RefreshExternalCollectionTaskSuite) TestOrganizeSegments_NewFragmentsUseBalance() {
	ctx := context.Background()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID: s.collectionID,
		TaskID:       s.taskID,
		CurrentSegments: []*datapb.SegmentInfo{
			{ID: 1, CollectionID: s.collectionID, NumOfRows: 1000},
		},
	}
	task := s.newTask(ctx, req)

	currentSegmentFragments := packed.SegmentFragments{
		1: []packed.Fragment{{FragmentID: 101, FilePath: "/data/file1.parquet", StartRow: 0, EndRow: 1000, RowCount: 1000}},
	}
	newFragments := []packed.Fragment{
		{FragmentID: 201, FilePath: "/data/file1.parquet", StartRow: 0, EndRow: 1000, RowCount: 1000},
		{FragmentID: 202, FilePath: "/data/file2.parquet", StartRow: 0, EndRow: 500, RowCount: 500},
	}
	created := []*datapb.SegmentInfo{{ID: 2, CollectionID: s.collectionID, NumOfRows: 500}}

	var gotOrphans []packed.Fragment
	mockBalance := mockey.Mock(mockey.GetMethod(task, "balanceFragmentsToSegments")).
		To(func(ctx context.Context, fragments []packed.Fragment) ([]*datapb.SegmentInfo, error) {
			gotOrphans = fragments
			return created, nil
		}).Build()
	defer mockBalance.UnPatch()

	result, err := task.organizeSegments(ctx, currentSegmentFragments, newFragments)
	s.NoError(err)
	s.ElementsMatch([]int64{1}, task.GetKeptSegmentIDs())
	s.Equal(created, task.GetUpdatedSegments())
	s.Equal([]*datapb.SegmentInfo{req.GetCurrentSegments()[0], created[0]}, result)
	s.Require().Len(gotOrphans, 1)
	s.Equal("/data/file2.parquet", gotOrphans[0].FilePath)
}

func (s *RefreshExternalCollectionTaskSuite) TestOrganizeSegments_RewritesSegmentMissingFunctionOutputs() {
	ctx := context.Background()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:  s.collectionID,
		TaskID:        s.taskID,
		StorageConfig: &indexpb.StorageConfig{RootPath: "files", StorageType: "local"},
		CurrentSegments: []*datapb.SegmentInfo{
			{
				ID:           1,
				CollectionID: s.collectionID,
				NumOfRows:    1000,
				ManifestPath: packed.MarshalManifestPath("files/insert_log/1000/2000/1", 1),
				Binlogs: []*datapb.FieldBinlog{
					{ChildFields: []int64{100}},
				},
			},
		},
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
				{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector, IsFunctionOutput: true},
			},
			Functions: []*schemapb.FunctionSchema{
				{Type: schemapb.FunctionType_BM25, InputFieldIds: []int64{100}, OutputFieldIds: []int64{101}},
			},
		},
	}
	task := s.newTask(ctx, req)

	currentSegmentFragments := packed.SegmentFragments{
		1: []packed.Fragment{{FragmentID: 101, FilePath: "/data/file1.parquet", StartRow: 0, EndRow: 1000, RowCount: 1000}},
	}
	newFragments := []packed.Fragment{
		{FragmentID: 201, FilePath: "/data/file1.parquet", StartRow: 0, EndRow: 1000, RowCount: 1000},
	}
	created := []*datapb.SegmentInfo{{ID: 2, CollectionID: s.collectionID, NumOfRows: 1000}}

	var checkedColumns []string
	mockHasColumns := mockey.Mock(packed.ManifestHasColumns).
		To(func(manifestPath string, storageConfig *indexpb.StorageConfig, columns []string) (bool, error) {
			checkedColumns = columns
			return false, nil
		}).Build()
	defer mockHasColumns.UnPatch()

	var gotOrphans []packed.Fragment
	mockBalance := mockey.Mock(mockey.GetMethod(task, "balanceFragmentsToSegments")).
		To(func(ctx context.Context, fragments []packed.Fragment) ([]*datapb.SegmentInfo, error) {
			gotOrphans = fragments
			return created, nil
		}).Build()
	defer mockBalance.UnPatch()

	result, err := task.organizeSegments(ctx, currentSegmentFragments, newFragments)
	s.NoError(err)
	s.Empty(task.GetKeptSegmentIDs())
	s.Equal(created, task.GetUpdatedSegments())
	s.Equal(created, result)
	s.Equal([]string{"101"}, checkedColumns)
	s.Require().Len(gotOrphans, 1)
	s.Equal("/data/file1.parquet", gotOrphans[0].FilePath)
}

func (s *RefreshExternalCollectionTaskSuite) TestOrganizeSegments_KeepsSegmentWithFunctionOutputs() {
	ctx := context.Background()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:  s.collectionID,
		TaskID:        s.taskID,
		StorageConfig: &indexpb.StorageConfig{RootPath: "files", StorageType: "local"},
		CurrentSegments: []*datapb.SegmentInfo{
			{
				ID:           1,
				CollectionID: s.collectionID,
				NumOfRows:    1000,
				ManifestPath: packed.MarshalManifestPath("files/insert_log/1000/2000/1", 1),
				Binlogs: []*datapb.FieldBinlog{
					{ChildFields: []int64{100}},
				},
			},
		},
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
				{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector, IsFunctionOutput: true},
			},
			Functions: []*schemapb.FunctionSchema{
				{Type: schemapb.FunctionType_BM25, InputFieldIds: []int64{100}, OutputFieldIds: []int64{101}},
			},
		},
	}
	task := s.newTask(ctx, req)

	currentSegmentFragments := packed.SegmentFragments{
		1: []packed.Fragment{{FragmentID: 101, FilePath: "/data/file1.parquet", StartRow: 0, EndRow: 1000, RowCount: 1000}},
	}
	newFragments := []packed.Fragment{
		{FragmentID: 201, FilePath: "/data/file1.parquet", StartRow: 0, EndRow: 1000, RowCount: 1000},
	}

	var checkedColumns []string
	mockHasColumns := mockey.Mock(packed.ManifestHasColumns).
		To(func(manifestPath string, storageConfig *indexpb.StorageConfig, columns []string) (bool, error) {
			checkedColumns = columns
			return true, nil
		}).Build()
	defer mockHasColumns.UnPatch()

	var gotOrphans []packed.Fragment
	mockBalance := mockey.Mock(mockey.GetMethod(task, "balanceFragmentsToSegments")).
		To(func(ctx context.Context, fragments []packed.Fragment) ([]*datapb.SegmentInfo, error) {
			gotOrphans = fragments
			return nil, nil
		}).Build()
	defer mockBalance.UnPatch()

	result, err := task.organizeSegments(ctx, currentSegmentFragments, newFragments)
	s.NoError(err)
	s.ElementsMatch([]int64{1}, task.GetKeptSegmentIDs())
	s.Empty(task.GetUpdatedSegments())
	s.Equal([]*datapb.SegmentInfo{req.GetCurrentSegments()[0]}, result)
	s.Equal([]string{"101"}, checkedColumns)
	s.Empty(gotOrphans)
}

func (s *RefreshExternalCollectionTaskSuite) TestOrganizeSegments_FunctionOutputColumnCheckError() {
	ctx := context.Background()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:  s.collectionID,
		TaskID:        s.taskID,
		StorageConfig: &indexpb.StorageConfig{RootPath: "files", StorageType: "local"},
		CurrentSegments: []*datapb.SegmentInfo{
			{ID: 1, CollectionID: s.collectionID, NumOfRows: 1000, ManifestPath: packed.MarshalManifestPath("files/insert_log/1000/2000/1", 1)},
		},
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
				{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector, IsFunctionOutput: true},
			},
			Functions: []*schemapb.FunctionSchema{
				{Type: schemapb.FunctionType_BM25, InputFieldIds: []int64{100}, OutputFieldIds: []int64{101}},
			},
		},
	}
	task := s.newTask(ctx, req)

	currentSegmentFragments := packed.SegmentFragments{
		1: []packed.Fragment{{FragmentID: 101, FilePath: "/data/file1.parquet", StartRow: 0, EndRow: 1000, RowCount: 1000}},
	}
	newFragments := []packed.Fragment{
		{FragmentID: 201, FilePath: "/data/file1.parquet", StartRow: 0, EndRow: 1000, RowCount: 1000},
	}

	mockHasColumns := mockey.Mock(packed.ManifestHasColumns).
		Return(false, fmt.Errorf("manifest read failed")).Build()
	defer mockHasColumns.UnPatch()

	result, err := task.organizeSegments(ctx, currentSegmentFragments, newFragments)
	s.Error(err)
	s.Nil(result)
	s.Contains(err.Error(), "check function output columns for segment 1")
	s.Contains(err.Error(), "manifest read failed")
}

func (s *RefreshExternalCollectionTaskSuite) TestFunctionOutputColumnNamesUseFunctionSchema() {
	schema := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
			{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector},
		},
		Functions: []*schemapb.FunctionSchema{
			{Type: schemapb.FunctionType_BM25, OutputFieldIds: []int64{101}, OutputFieldNames: []string{"sparse"}},
		},
	}

	columns, err := functionOutputColumnNames(schema)
	s.NoError(err)
	s.Equal([]string{"101"}, columns)
}

func (s *RefreshExternalCollectionTaskSuite) TestFunctionOutputFieldsUseFunctionSchema() {
	schema := &schemapb.CollectionSchema{
		Name: "test_schema",
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
			{
				FieldID:    101,
				Name:       "embedding",
				DataType:   schemapb.DataType_FloatVector,
				TypeParams: []*commonpb.KeyValuePair{{Key: "dim", Value: "4"}},
			},
		},
		Functions: []*schemapb.FunctionSchema{
			{
				Type:             schemapb.FunctionType_TextEmbedding,
				InputFieldIds:    []int64{100},
				OutputFieldNames: []string{"embedding"},
			},
		},
	}

	outputFields, outputSchema, err := buildOutputSchema(schema)
	s.NoError(err)
	s.Require().Len(outputFields, 1)
	s.Equal(int64(101), outputFields[0].GetFieldID())
	s.Equal("test_schema", outputSchema.GetName())
	s.Equal(outputFields, outputSchema.GetFields())

	bytes, err := estimateFunctionOutputBytesPerRow(schema)
	s.NoError(err)
	s.Equal(int64(16), bytes)

	_, executionSchema, _, err := buildFunctionExecutionSchema(schema)
	s.NoError(err)
	s.Equal([]int64{100, 101}, []int64{
		executionSchema.GetFields()[0].GetFieldID(),
		executionSchema.GetFields()[1].GetFieldID(),
	})
}

func (s *RefreshExternalCollectionTaskSuite) TestFunctionOutputFieldsRejectMissingFunctionOutputs() {
	schema := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
		},
		Functions: []*schemapb.FunctionSchema{
			{Type: schemapb.FunctionType_TextEmbedding, OutputFieldIds: []int64{101}},
		},
	}

	_, err := functionOutputColumnNames(schema)
	s.Error(err)
	s.Contains(err.Error(), "function output field id 101 not found in schema")

	schema.GetFunctions()[0].OutputFieldIds = nil
	schema.GetFunctions()[0].OutputFieldNames = []string{"embedding"}
	_, err = functionOutputColumnNames(schema)
	s.Error(err)
	s.Contains(err.Error(), "function output field embedding not found in schema")
}

func (s *RefreshExternalCollectionTaskSuite) TestFunctionOutputFieldsBranches() {
	outputFields, err := functionOutputFields(nil)
	s.NoError(err)
	s.Nil(outputFields)

	schema := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 0, Name: "zero", DataType: schemapb.DataType_VarChar, IsFunctionOutput: true},
			{FieldID: 101, Name: "direct", DataType: schemapb.DataType_VarChar, IsFunctionOutput: true},
			{FieldID: 102, Name: "named", DataType: schemapb.DataType_SparseFloatVector},
		},
		Functions: []*schemapb.FunctionSchema{
			{
				OutputFieldIds:   []int64{0, 102},
				OutputFieldNames: []string{"", "named"},
			},
		},
	}

	outputFields, err = functionOutputFields(schema)
	s.NoError(err)
	s.Equal([]int64{101, 102}, []int64{
		outputFields[0].GetFieldID(),
		outputFields[1].GetFieldID(),
	})
}

func (s *RefreshExternalCollectionTaskSuite) TestBuildFunctionExecutionSchemaBranches() {
	_, _, _, err := buildFunctionExecutionSchema(nil)
	s.Error(err)
	s.Contains(err.Error(), "collection schema is nil")

	_, _, err = buildOutputSchema(&schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar},
		},
		Functions: []*schemapb.FunctionSchema{
			{Type: schemapb.FunctionType_BM25, OutputFieldIds: []int64{101}},
		},
	})
	s.Error(err)
	s.Contains(err.Error(), "function output field id 101 not found")

	_, _, _, err = buildFunctionExecutionSchema(&schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector, IsFunctionOutput: true},
		},
		Functions: []*schemapb.FunctionSchema{
			{Type: schemapb.FunctionType_BM25, InputFieldIds: []int64{999}, OutputFieldIds: []int64{101}},
		},
	})
	s.Error(err)
	s.Contains(err.Error(), "function input field id 999 not found in schema")

	_, _, _, err = buildFunctionExecutionSchema(&schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar},
		},
		Functions: []*schemapb.FunctionSchema{
			{Type: schemapb.FunctionType_BM25, InputFieldIds: []int64{100}, OutputFieldIds: []int64{101}},
		},
	})
	s.Error(err)
	s.Contains(err.Error(), "function output field id 101 not found")

	_, _, _, err = buildFunctionExecutionSchema(&schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: common.VirtualPKFieldName, DataType: schemapb.DataType_Int64},
			{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector, IsFunctionOutput: true},
		},
		Functions: []*schemapb.FunctionSchema{
			{Type: schemapb.FunctionType_BM25, InputFieldIds: []int64{100}, OutputFieldIds: []int64{101}},
		},
	})
	s.Error(err)
	s.Contains(err.Error(), "no source input columns")

	_, _, _, err = buildFunctionExecutionSchema(&schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar},
		},
		Functions: []*schemapb.FunctionSchema{
			{Type: schemapb.FunctionType_BM25, InputFieldIds: []int64{100}, OutputFieldIds: []int64{100}},
		},
	})
	s.Error(err)
	s.Contains(err.Error(), "no source input columns")

	inputSchema, executionSchema, requiredFields, err := buildFunctionExecutionSchema(&schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar},
			{FieldID: 100, Name: "text_dup", DataType: schemapb.DataType_VarChar},
			{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector, IsFunctionOutput: true},
		},
		Functions: []*schemapb.FunctionSchema{
			{Type: schemapb.FunctionType_BM25, InputFieldIds: []int64{100}, OutputFieldIds: []int64{101}},
		},
	})
	s.NoError(err)
	s.Require().NotNil(inputSchema)
	s.Require().NotNil(executionSchema)
	s.True(requiredFields.Contain(int64(100)))
	s.Len(executionSchema.GetFields(), 2)
}

func (s *RefreshExternalCollectionTaskSuite) TestSegmentHasFunctionOutputColumnsBranches() {
	task := s.newTask(context.Background(), &datapb.RefreshExternalCollectionTaskRequest{})

	hasColumns, err := task.segmentHasFunctionOutputColumns(&datapb.SegmentInfo{}, nil)
	s.NoError(err)
	s.True(hasColumns)

	hasColumns, err = task.segmentHasFunctionOutputColumns(&datapb.SegmentInfo{}, []string{"101"})
	s.NoError(err)
	s.False(hasColumns)

	mock := mockey.Mock(packed.ManifestHasColumns).To(
		func(string, *indexpb.StorageConfig, []string) (bool, error) {
			s.FailNow("ManifestHasColumns should not be called when ChildFields already prove the output field exists")
			return false, nil
		}).Build()
	defer mock.UnPatch()
	hasColumns, err = task.segmentHasFunctionOutputColumns(&datapb.SegmentInfo{
		ManifestPath: "manifest",
		Binlogs: []*datapb.FieldBinlog{
			{ChildFields: []int64{100, 101}},
		},
	}, []string{"101"})
	s.NoError(err)
	s.True(hasColumns)
}

func (s *RefreshExternalCollectionTaskSuite) TestOrganizeSegmentsFunctionOutputColumnError() {
	ctx := context.Background()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID: s.collectionID,
		TaskID:       s.taskID,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
			},
			Functions: []*schemapb.FunctionSchema{
				{Type: schemapb.FunctionType_BM25, OutputFieldIds: []int64{101}},
			},
		},
	}
	task := s.newTask(ctx, req)

	result, err := task.organizeSegments(ctx, nil, nil)
	s.Error(err)
	s.Nil(result)
	s.Contains(err.Error(), "resolve function output columns")

	_, err = estimateFunctionOutputBytesPerRow(req.GetSchema())
	s.Error(err)
	s.Contains(err.Error(), "function output field id 101 not found")
}

func (s *RefreshExternalCollectionTaskSuite) TestOrganizeSegmentsBalanceError() {
	ctx := context.Background()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID: s.collectionID,
		TaskID:       s.taskID,
	}
	task := s.newTask(ctx, req)
	mockBalance := mockey.Mock(mockey.GetMethod(task, "balanceFragmentsToSegments")).
		Return(nil, fmt.Errorf("balance failed")).Build()
	defer mockBalance.UnPatch()

	result, err := task.organizeSegments(ctx, nil, []packed.Fragment{{FragmentID: 1, FilePath: "/data/file.parquet", RowCount: 100}})
	s.Error(err)
	s.Nil(result)
}

func (s *RefreshExternalCollectionTaskSuite) TestOrganizeSegmentsContextCanceledDuringLoops() {
	testCases := []struct {
		name                    string
		cancelAt                int
		currentSegments         []*datapb.SegmentInfo
		currentSegmentFragments packed.SegmentFragments
		newFragments            []packed.Fragment
	}{
		{
			name:            "current segment loop",
			cancelAt:        2,
			currentSegments: []*datapb.SegmentInfo{{ID: 1, CollectionID: s.collectionID, NumOfRows: 100}},
			currentSegmentFragments: packed.SegmentFragments{
				1: []packed.Fragment{{FragmentID: 1, FilePath: "/data/file1.parquet", RowCount: 100}},
			},
			newFragments: []packed.Fragment{{FragmentID: 1, FilePath: "/data/file1.parquet", RowCount: 100}},
		},
		{
			name:            "kept fragment loop",
			cancelAt:        3,
			currentSegments: []*datapb.SegmentInfo{{ID: 1, CollectionID: s.collectionID, NumOfRows: 100}},
			currentSegmentFragments: packed.SegmentFragments{
				1: []packed.Fragment{{FragmentID: 1, FilePath: "/data/file1.parquet", RowCount: 100}},
			},
			newFragments: []packed.Fragment{{FragmentID: 1, FilePath: "/data/file1.parquet", RowCount: 100}},
		},
		{
			name:         "new fragment loop",
			cancelAt:     2,
			newFragments: []packed.Fragment{{FragmentID: 1, FilePath: "/data/file1.parquet", RowCount: 100}},
		},
	}

	for _, tc := range testCases {
		s.T().Run(tc.name, func(t *testing.T) {
			ctx := context.Background()
			req := &datapb.RefreshExternalCollectionTaskRequest{
				CollectionID:    s.collectionID,
				TaskID:          s.taskID,
				CurrentSegments: tc.currentSegments,
			}
			task := s.newTask(ctx, req)

			var calls int
			mockEnsure := mockey.Mock(ensureContext).To(func(ctx context.Context) error {
				calls++
				if calls == tc.cancelAt {
					return context.Canceled
				}
				return nil
			}).Build()
			defer mockEnsure.UnPatch()

			result, err := task.organizeSegments(ctx, tc.currentSegmentFragments, tc.newFragments)
			s.ErrorIs(err, context.Canceled)
			s.Nil(result)
		})
	}
}

func (s *RefreshExternalCollectionTaskSuite) TestOrganizeSegments_NewFragmentsAdded() {
	s.T().Skip("Skip test that requires CGO FFI calls for manifest creation")
}

func (s *RefreshExternalCollectionTaskSuite) TestSplitFileToFragments_SmallFile() {
	// File smaller than limit - should return single fragment
	fragments := packed.SplitFileToFragments("/data/small.parquet", 500000, packed.DefaultFragmentRowLimit, packed.NewFragmentIDGenerator(0))

	s.Len(fragments, 1)
	s.Equal(int64(0), fragments[0].FragmentID)
	s.Equal("/data/small.parquet", fragments[0].FilePath)
	s.Equal(int64(0), fragments[0].StartRow)
	s.Equal(int64(500000), fragments[0].EndRow)
	s.Equal(int64(500000), fragments[0].RowCount)
}

func (s *RefreshExternalCollectionTaskSuite) TestSplitFileToFragments_ExactLimit() {
	// File exactly at limit - should return single fragment
	fragments := packed.SplitFileToFragments("/data/exact.parquet", packed.DefaultFragmentRowLimit, packed.DefaultFragmentRowLimit, packed.NewFragmentIDGenerator(0))

	s.Len(fragments, 1)
	s.Equal(int64(0), fragments[0].FragmentID)
	s.Equal(int64(0), fragments[0].StartRow)
	s.Equal(int64(packed.DefaultFragmentRowLimit), fragments[0].EndRow)
	s.Equal(int64(packed.DefaultFragmentRowLimit), fragments[0].RowCount)
}

func (s *RefreshExternalCollectionTaskSuite) TestSplitFileToFragments_LargeFile() {
	// File with 2.5 million rows - should split into 3 fragments
	totalRows := int64(2500000)
	fragments := packed.SplitFileToFragments("/data/large.parquet", totalRows, packed.DefaultFragmentRowLimit, packed.NewFragmentIDGenerator(0))

	s.Len(fragments, 3)

	// First fragment: 0-1000000
	s.Equal(int64(0), fragments[0].FragmentID)
	s.Equal("/data/large.parquet", fragments[0].FilePath)
	s.Equal(int64(0), fragments[0].StartRow)
	s.Equal(int64(1000000), fragments[0].EndRow)
	s.Equal(int64(1000000), fragments[0].RowCount)

	// Second fragment: 1000000-2000000
	s.Equal(int64(1), fragments[1].FragmentID)
	s.Equal("/data/large.parquet", fragments[1].FilePath)
	s.Equal(int64(1000000), fragments[1].StartRow)
	s.Equal(int64(2000000), fragments[1].EndRow)
	s.Equal(int64(1000000), fragments[1].RowCount)

	// Third fragment: 2000000-2500000
	s.Equal(int64(2), fragments[2].FragmentID)
	s.Equal("/data/large.parquet", fragments[2].FilePath)
	s.Equal(int64(2000000), fragments[2].StartRow)
	s.Equal(int64(2500000), fragments[2].EndRow)
	s.Equal(int64(500000), fragments[2].RowCount)
}

func (s *RefreshExternalCollectionTaskSuite) TestSplitFileToFragments_BaseFragmentID() {
	// Test with non-zero base fragment ID
	fragments := packed.SplitFileToFragments("/data/test.parquet", 2500000, packed.DefaultFragmentRowLimit, packed.NewFragmentIDGenerator(100))

	s.Len(fragments, 3)
	s.Equal(int64(100), fragments[0].FragmentID)
	s.Equal(int64(101), fragments[1].FragmentID)
	s.Equal(int64(102), fragments[2].FragmentID)
}

func (s *RefreshExternalCollectionTaskSuite) TestSplitFileToFragments_ZeroRows() {
	// Empty file - should return single fragment with zero rows
	fragments := packed.SplitFileToFragments("/data/empty.parquet", 0, packed.DefaultFragmentRowLimit, packed.NewFragmentIDGenerator(0))

	s.Len(fragments, 1)
	s.Equal(int64(0), fragments[0].RowCount)
}

func (s *RefreshExternalCollectionTaskSuite) TestSplitFileToFragments_TenMillionRows() {
	// 10 million rows - should split into 10 fragments
	totalRows := int64(10000000)
	fragments := packed.SplitFileToFragments("/data/huge.parquet", totalRows, packed.DefaultFragmentRowLimit, packed.NewFragmentIDGenerator(0))

	s.Len(fragments, 10)

	// Verify total rows across all fragments
	var totalFragmentRows int64
	for _, f := range fragments {
		totalFragmentRows += f.RowCount
	}
	s.Equal(totalRows, totalFragmentRows)

	// Verify fragment IDs are sequential
	for i, f := range fragments {
		s.Equal(int64(i), f.FragmentID)
	}

	// Verify ranges are continuous
	for i := 1; i < len(fragments); i++ {
		s.Equal(fragments[i-1].EndRow, fragments[i].StartRow)
	}
}

func (s *RefreshExternalCollectionTaskSuite) TestCreateManifestForSegment() {
	ctx := context.Background()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:  s.collectionID,
		PartitionID:   2000,
		StorageConfig: &indexpb.StorageConfig{RootPath: "files", StorageType: "local"},
	}
	task := s.newTask(ctx, req)
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}
	task.columns = []string{"col1"}

	var gotBasePath string
	var gotStorageConfig *indexpb.StorageConfig
	var gotExtfs packed.ExternalSpecContext
	mockCreate := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(
			ctx context.Context,
			basePath string,
			format string,
			columns []string,
			fragments []packed.Fragment,
			storageConfig *indexpb.StorageConfig,
			extfs packed.ExternalSpecContext,
		) (string, error) {
			gotBasePath = basePath
			gotStorageConfig = storageConfig
			gotExtfs = extfs
			return "manifest-path", nil
		}).Build()
	defer mockCreate.UnPatch()

	manifestPath, err := task.createManifestForSegment(ctx, 3000, []packed.Fragment{{FragmentID: 1}})
	s.NoError(err)
	s.Equal("manifest-path", manifestPath)
	s.Equal("files/insert_log/1000/2000/3000", gotBasePath)
	s.Same(req.GetStorageConfig(), gotStorageConfig)
	s.Equal(s.collectionID, gotExtfs.CollectionID)
	s.Empty(gotExtfs.Source)
	s.Empty(gotExtfs.Spec)
}

func (s *RefreshExternalCollectionTaskSuite) TestCreateManifestWithFunctionsUsesInsertLogBasePath() {
	paramtable.Init()

	ctx := context.Background()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:  s.collectionID,
		PartitionID:   2000,
		StorageConfig: &indexpb.StorageConfig{RootPath: "files", StorageType: "local"},
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
				{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector, IsFunctionOutput: true},
			},
			Functions: []*schemapb.FunctionSchema{
				{
					Name:           "bm25",
					Type:           schemapb.FunctionType_BM25,
					InputFieldIds:  []int64{100},
					OutputFieldIds: []int64{101},
				},
			},
		},
	}
	task := s.newTask(ctx, req)
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	var gotBasePath string
	var gotStorageConfig *indexpb.StorageConfig
	mockExec := mockey.Mock(ExecuteFunctionsForSegment).
		To(func(
			ctx context.Context,
			schema *schemapb.CollectionSchema,
			fragments []packed.Fragment,
			format string,
			storageConfig *indexpb.StorageConfig,
			collectionID int64,
			segmentID int64,
			basePath string,
			clusterID string,
		) (string, error) {
			gotBasePath = basePath
			gotStorageConfig = storageConfig
			return "manifest-path", nil
		}).Build()
	defer mockExec.UnPatch()

	manifestPath, err := task.createManifestWithFunctions(ctx, 3000, []packed.Fragment{{FragmentID: 1}})
	s.NoError(err)
	s.Equal("manifest-path", manifestPath)
	s.Equal("files/insert_log/1000/2000/3000", gotBasePath)
	s.Same(req.GetStorageConfig(), gotStorageConfig)
}

func (s *RefreshExternalCollectionTaskSuite) TestExecuteFunctionsForSegmentUsesProvidedBasePath() {
	ctx := context.Background()
	storageConfig := &indexpb.StorageConfig{RootPath: "files", StorageType: "local"}
	schema := &schemapb.CollectionSchema{
		ExternalSource: "s3://bucket/path",
		ExternalSpec:   `{"format":"parquet"}`,
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
		},
	}
	fragments := []packed.Fragment{{FragmentID: 1, FilePath: "s3://bucket/path/file.parquet"}}

	var gotBasePath string
	var gotColumns []string
	var gotFragments []packed.Fragment
	var gotStorageConfig *indexpb.StorageConfig
	mockCreate := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(
			ctx context.Context,
			basePath string,
			format string,
			columns []string,
			fragments []packed.Fragment,
			storageConfig *indexpb.StorageConfig,
			extfs packed.ExternalSpecContext,
		) (string, error) {
			gotBasePath = basePath
			gotColumns = columns
			gotFragments = fragments
			gotStorageConfig = storageConfig
			return "", fmt.Errorf("mock create manifest")
		}).Build()
	defer mockCreate.UnPatch()

	manifestPath, err := ExecuteFunctionsForSegment(
		ctx,
		schema,
		fragments,
		"parquet",
		storageConfig,
		s.collectionID,
		3000,
		"files/insert_log/1000/2000/3000",
		"cluster",
	)
	s.Error(err)
	s.Contains(err.Error(), "create input manifest")
	s.Empty(manifestPath)
	s.Equal("files/insert_log/1000/2000/3000", gotBasePath)
	s.Equal([]string{"text_col"}, gotColumns)
	s.Equal(fragments, gotFragments)
	s.Same(storageConfig, gotStorageConfig)
}

func (s *RefreshExternalCollectionTaskSuite) TestExecuteFunctionsForSegmentRequiresFunctionOutputs() {
	ctx := context.Background()
	schema := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
		},
	}

	mockCreate := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(
			ctx context.Context,
			basePath string,
			format string,
			columns []string,
			fragments []packed.Fragment,
			storageConfig *indexpb.StorageConfig,
			extfs packed.ExternalSpecContext,
		) (string, error) {
			return packed.MarshalManifestPath(basePath, 42), nil
		}).Build()
	defer mockCreate.UnPatch()

	manifestPath, err := ExecuteFunctionsForSegment(
		ctx,
		schema,
		[]packed.Fragment{{FragmentID: 1}},
		"parquet",
		&indexpb.StorageConfig{RootPath: "files", StorageType: "local"},
		s.collectionID,
		3000,
		"files/insert_log/1000/2000/3000",
		"cluster",
	)
	s.Error(err)
	s.Contains(err.Error(), "no function output fields")
	s.Empty(manifestPath)
}

func (s *RefreshExternalCollectionTaskSuite) TestFunctionExecutorSchemaHelpers() {
	schema := &schemapb.CollectionSchema{
		Name:           "test_schema",
		ExternalSource: "s3://bucket/path",
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
			{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector, IsFunctionOutput: true},
			{FieldID: 102, Name: "vec", DataType: schemapb.DataType_FloatVector, ExternalField: "vec_col", TypeParams: []*commonpb.KeyValuePair{{Key: "dim", Value: "2"}}},
			{FieldID: 103, Name: common.VirtualPKFieldName, DataType: schemapb.DataType_Int64},
		},
		Functions: []*schemapb.FunctionSchema{
			{Type: schemapb.FunctionType_BM25, InputFieldIds: []int64{100}, OutputFieldIds: []int64{101}},
		},
	}

	inputSchema, executionSchema, requiredFields, err := buildFunctionExecutionSchema(schema)
	s.NoError(err)
	s.True(requiredFields.Contain(int64(100)))
	s.Require().Len(inputSchema.GetFields(), 1)
	s.Equal("text", inputSchema.GetFields()[0].GetName())
	s.Require().Len(executionSchema.GetFields(), 2)
	s.Equal([]int64{100, 101}, []int64{
		executionSchema.GetFields()[0].GetFieldID(),
		executionSchema.GetFields()[1].GetFieldID(),
	})

	outputFields, outputSchema, err := buildOutputSchema(schema)
	s.NoError(err)
	s.Require().Len(outputFields, 1)
	s.Equal(int64(101), outputFields[0].GetFieldID())
	s.Equal("test_schema", outputSchema.GetName())
	s.Equal(outputFields, outputSchema.GetFields())

	_, _, _, err = buildFunctionExecutionSchema(&schemapb.CollectionSchema{
		ExternalSource: "s3://bucket/path",
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar},
			{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector, IsFunctionOutput: true},
		},
		Functions: []*schemapb.FunctionSchema{
			{Type: schemapb.FunctionType_BM25, InputFieldIds: []int64{100}, OutputFieldIds: []int64{101}},
		},
	})
	s.Error(err)
	s.Contains(err.Error(), "has no external_field")
}

func (s *RefreshExternalCollectionTaskSuite) TestBM25Accumulators() {
	schema := &schemapb.CollectionSchema{
		Functions: []*schemapb.FunctionSchema{
			{Type: schemapb.FunctionType_BM25, OutputFieldIds: []int64{101, 102}},
			{Type: schemapb.FunctionType_TextEmbedding, OutputFieldIds: []int64{103}},
		},
	}

	acc := newBM25Accumulators(schema)
	s.Require().Len(acc, 2)
	s.NotNil(acc[101])
	s.NotNil(acc[102])
	s.Nil(acc[103])

	err := accumulateBM25Stats(&storage.InsertData{Data: map[int64]storage.FieldData{}}, map[int64]*storage.BM25Stats{
		101: storage.NewBM25Stats(),
	})
	s.Error(err)
	s.Contains(err.Error(), "missing from batch")

	err = accumulateBM25Stats(&storage.InsertData{Data: map[int64]storage.FieldData{
		101: &storage.StringFieldData{Data: []string{"wrong"}},
	}}, map[int64]*storage.BM25Stats{101: storage.NewBM25Stats()})
	s.Error(err)
	s.Contains(err.Error(), "wrong type")

	stats := storage.NewBM25Stats()
	err = accumulateBM25Stats(&storage.InsertData{Data: map[int64]storage.FieldData{
		101: &storage.SparseFloatVectorFieldData{
			SparseFloatArray: schemapb.SparseFloatArray{
				Contents: [][]byte{
					typeutil.CreateSparseFloatRow([]uint32{1, 2}, []float32{3, 4}),
					typeutil.CreateSparseFloatRow([]uint32{2}, []float32{5}),
				},
			},
		},
	}}, map[int64]*storage.BM25Stats{101: stats})
	s.NoError(err)
	s.Equal(int64(2), stats.NumRow())
}

func (s *RefreshExternalCollectionTaskSuite) TestFinalizeBM25Stats() {
	ctx := context.Background()
	storageConfig := &indexpb.StorageConfig{RootPath: "files", StorageType: "local"}
	manifestPath := packed.MarshalManifestPath("files/insert_log/1000/2000/3000", 42)

	gotManifest, err := finalizeBM25Stats(ctx, nil, storageConfig, manifestPath)
	s.NoError(err)
	s.Equal(manifestPath, gotManifest)

	gotManifest, err = finalizeBM25Stats(ctx, map[int64]*storage.BM25Stats{
		101: storage.NewBM25Stats(),
	}, storageConfig, "bad manifest")
	s.Error(err)
	s.Empty(gotManifest)

	stats := storage.NewBM25Stats()
	stats.Append(map[uint32]float32{1: 2})
	var gotFilePath string
	var gotEntries []packed.StatEntry
	mockWrite := mockey.Mock(packed.WriteFile).
		To(func(storageConfig *indexpb.StorageConfig, filePath string, data []byte) error {
			gotFilePath = filePath
			s.NotEmpty(data)
			return nil
		}).Build()
	defer mockWrite.UnPatch()
	mockCommit := mockey.Mock(packed.CommitManifestUpdates).
		To(func(basePath string, version int64, storageConfig *indexpb.StorageConfig, updates *packed.ManifestUpdates) (string, error) {
			s.Equal("files/insert_log/1000/2000/3000", basePath)
			s.Equal(int64(42), version)
			gotEntries = updates.Stats
			return "updated-manifest", nil
		}).Build()
	defer mockCommit.UnPatch()

	gotManifest, err = finalizeBM25Stats(ctx, map[int64]*storage.BM25Stats{101: stats}, storageConfig, manifestPath)
	s.NoError(err)
	s.Equal("updated-manifest", gotManifest)
	s.Equal("files/insert_log/1000/2000/3000/_stats/bm25.101/0", gotFilePath)
	s.Require().Len(gotEntries, 1)
	s.Equal("bm25.101", gotEntries[0].Key)
}

func (s *RefreshExternalCollectionTaskSuite) TestFinalizeBM25StatsErrors() {
	ctx := context.Background()
	storageConfig := &indexpb.StorageConfig{RootPath: "files", StorageType: "local"}
	manifestPath := packed.MarshalManifestPath("files/insert_log/1000/2000/3000", 42)
	stats := storage.NewBM25Stats()
	stats.Append(map[uint32]float32{1: 2})

	mockSerialize := mockey.Mock(mockey.GetMethod(stats, "Serialize")).Return(nil, fmt.Errorf("serialize failed")).Build()
	gotManifest, err := finalizeBM25Stats(ctx, map[int64]*storage.BM25Stats{101: stats}, storageConfig, manifestPath)
	s.Error(err)
	s.Empty(gotManifest)
	mockSerialize.UnPatch()

	mockWrite := mockey.Mock(packed.WriteFile).Return(fmt.Errorf("write failed")).Build()
	gotManifest, err = finalizeBM25Stats(ctx, map[int64]*storage.BM25Stats{101: stats}, storageConfig, manifestPath)
	s.Error(err)
	s.Empty(gotManifest)
	mockWrite.UnPatch()

	mockWrite = mockey.Mock(packed.WriteFile).Return(nil).Build()
	defer mockWrite.UnPatch()
	mockCommit := mockey.Mock(packed.CommitManifestUpdates).Return("", fmt.Errorf("commit failed")).Build()
	defer mockCommit.UnPatch()
	gotManifest, err = finalizeBM25Stats(ctx, map[int64]*storage.BM25Stats{101: stats}, storageConfig, manifestPath)
	s.Error(err)
	s.Empty(gotManifest)
}

func (s *RefreshExternalCollectionTaskSuite) TestWriteOutputBatch() {
	outputSchema := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector},
		},
	}
	outputArrow, err := storage.ConvertToArrowSchema(outputSchema, true)
	s.NoError(err)

	writer := &packed.FFIPackedWriter{}
	var wroteRows int64
	mockWrite := mockey.Mock(mockey.GetMethod(writer, "WriteRecordBatch")).
		To(func(recordBatch arrow.Record) error {
			wroteRows = recordBatch.NumRows()
			return nil
		}).Build()
	defer mockWrite.UnPatch()

	err = writeOutputBatch(&storage.InsertData{Data: map[int64]storage.FieldData{
		101: &storage.SparseFloatVectorFieldData{
			SparseFloatArray: schemapb.SparseFloatArray{
				Contents: [][]byte{typeutil.CreateSparseFloatRow([]uint32{1}, []float32{2})},
			},
		},
	}}, outputSchema, outputArrow, writer)
	s.NoError(err)
	s.Equal(int64(1), wroteRows)

	err = writeOutputBatch(&storage.InsertData{Data: map[int64]storage.FieldData{}}, outputSchema, outputArrow, writer)
	s.Error(err)
}

func (s *RefreshExternalCollectionTaskSuite) TestOpenInputReader() {
	schema := &schemapb.CollectionSchema{
		ExternalSource: "s3://bucket/path",
		ExternalSpec:   `{"format":"parquet"}`,
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
			{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector, IsFunctionOutput: true},
			{FieldID: 102, Name: "vec", DataType: schemapb.DataType_FloatVector, ExternalField: "vec_col", TypeParams: []*commonpb.KeyValuePair{{Key: "dim", Value: "2"}}},
		},
	}
	storageConfig := &indexpb.StorageConfig{RootPath: "files", StorageType: "local"}
	mockEncryption := mockey.Mock(hookutil.IsClusterEncryptionEnabled).Return(false).Build()
	defer mockEncryption.UnPatch()

	var gotColumns []string
	var gotExt packed.ExternalReaderContext
	var gotSchema *arrow.Schema
	mockReader := mockey.Mock(packed.NewFFIPackedReader).
		To(func(
			manifestPath string,
			schema *arrow.Schema,
			neededColumns []string,
			bufferSize int64,
			storageConfig *indexpb.StorageConfig,
			storagePluginContext *indexcgopb.StoragePluginContext,
			ext packed.ExternalReaderContext,
		) (*packed.FFIPackedReader, error) {
			gotColumns = neededColumns
			gotSchema = schema
			gotExt = ext
			return &packed.FFIPackedReader{}, nil
		}).Build()

	inputSchema, _, _, err := buildFunctionExecutionSchema(&schemapb.CollectionSchema{
		ExternalSource: schema.GetExternalSource(),
		ExternalSpec:   schema.GetExternalSpec(),
		Fields:         schema.GetFields(),
		Functions: []*schemapb.FunctionSchema{
			{Type: schemapb.FunctionType_BM25, InputFieldIds: []int64{100}, OutputFieldIds: []int64{101}},
		},
	})
	s.NoError(err)
	reader, err := openInputReader(context.Background(), schema, "manifest", inputSchema, storageConfig, s.collectionID)
	s.NoError(err)
	s.NotNil(reader)
	s.Equal([]string{"text_col"}, gotColumns)
	s.Require().NotNil(gotSchema)
	s.Equal(1, gotSchema.NumFields())
	s.Equal("text_col", gotSchema.Field(0).Name)
	s.Equal(arrow.BinaryTypes.String, gotSchema.Field(0).Type)
	s.Equal(s.collectionID, gotExt.CollectionID)
	s.Equal("s3://bucket/path", gotExt.Source)
	s.Equal(`{"format":"parquet"}`, gotExt.Spec)
	mockReader.UnPatch()

	mockReader = mockey.Mock(packed.NewFFIPackedReader).Return(nil, fmt.Errorf("open failed")).Build()
	defer mockReader.UnPatch()
	reader, err = openInputReader(context.Background(), schema, "manifest", inputSchema, storageConfig, s.collectionID)
	s.Error(err)
	s.Nil(reader)
	s.Contains(err.Error(), "open input manifest")

	reader, err = openInputReader(context.Background(), &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{{FieldID: 100, Name: "bad", DataType: schemapb.DataType_None}},
	}, "manifest", &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{{FieldID: 100, Name: "bad", DataType: schemapb.DataType_None}},
	}, storageConfig, s.collectionID)
	s.Error(err)
	s.Nil(reader)
	s.Contains(err.Error(), "open input manifest")
}

func (s *RefreshExternalCollectionTaskSuite) TestStreamBatchesSuccess() {
	schema := &schemapb.CollectionSchema{DbName: "db"}
	writer := &packed.FFIPackedWriter{}
	executionSchema := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar},
		},
	}
	reader := &fakeRecordReader{
		records: []storage.Record{s.makeStringStorageRecord(100, "text_col", []string{"hello"})},
	}
	mockRun := mockey.Mock(embedding.RunAll).
		To(func(ctx context.Context, schema *schemapb.CollectionSchema, data *storage.InsertData, opts embedding.RunOptions) error {
			s.Equal("cluster", opts.ClusterID)
			s.Equal("db", opts.DBName)
			s.Equal(1, data.GetRowNum())
			return nil
		}).Build()
	defer mockRun.UnPatch()
	mockAcc := mockey.Mock(accumulateBM25Stats).Return(nil).Build()
	defer mockAcc.UnPatch()
	mockWrite := mockey.Mock(writeOutputBatch).Return(nil).Build()
	defer mockWrite.UnPatch()

	rows, err := streamBatches(context.Background(), schema, executionSchema, nil, nil,
		typeutil.NewSet[int64](100), reader, writer, nil, "cluster")
	s.NoError(err)
	s.Equal(int64(1), rows)
}

func (s *RefreshExternalCollectionTaskSuite) TestStreamBatchesErrors() {
	schema := &schemapb.CollectionSchema{}
	executionSchema := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar},
		},
	}
	writer := &packed.FFIPackedWriter{}
	requiredFields := typeutil.NewSet[int64](100)

	reader := &fakeRecordReader{errs: []error{fmt.Errorf("read failed")}}
	rows, err := streamBatches(context.Background(), schema, executionSchema, nil, nil,
		requiredFields, reader, writer, nil, "cluster")
	s.Error(err)
	s.Equal(int64(0), rows)

	reader = &fakeRecordReader{
		records: []storage.Record{s.makeStringStorageRecord(999, "wrong_col", []string{"hello"})},
	}
	rows, err = streamBatches(context.Background(), schema, executionSchema, nil, nil,
		requiredFields, reader, writer, nil, "cluster")
	s.Error(err)
	s.Equal(int64(0), rows)

	reader = &fakeRecordReader{}
	rows, err = streamBatches(context.Background(), schema, executionSchema, nil, nil,
		requiredFields, reader, writer, nil, "cluster")
	s.NoError(err)
	s.Equal(int64(0), rows)

	reader = &fakeRecordReader{records: []storage.Record{nil}}
	rows, err = streamBatches(context.Background(), schema, executionSchema, nil, nil,
		requiredFields, reader, writer, nil, "cluster")
	s.NoError(err)
	s.Equal(int64(0), rows)
}

func (s *RefreshExternalCollectionTaskSuite) TestStreamBatchesProcessingErrors() {
	schema := &schemapb.CollectionSchema{DbName: "db"}
	writer := &packed.FFIPackedWriter{}
	executionSchema := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar},
		},
	}
	requiredFields := typeutil.NewSet[int64](100)

	s.T().Run("empty batch", func(t *testing.T) {
		reader := &fakeRecordReader{
			records: []storage.Record{s.makeStringStorageRecord(100, "text_col", nil)},
		}
		rows, err := streamBatches(context.Background(), schema, executionSchema, nil, nil,
			requiredFields, reader, writer, nil, "cluster")
		s.NoError(err)
		s.Equal(int64(0), rows)
	})

	s.T().Run("function error", func(t *testing.T) {
		reader := &fakeRecordReader{
			records: []storage.Record{s.makeStringStorageRecord(100, "text_col", []string{"hello"})},
		}
		mockRun := mockey.Mock(embedding.RunAll).Return(fmt.Errorf("run failed")).Build()
		defer mockRun.UnPatch()
		rows, err := streamBatches(context.Background(), schema, executionSchema, nil, nil,
			requiredFields, reader, writer, nil, "cluster")
		s.Error(err)
		s.Equal(int64(0), rows)
	})

	s.T().Run("bm25 stats error", func(t *testing.T) {
		reader := &fakeRecordReader{
			records: []storage.Record{s.makeStringStorageRecord(100, "text_col", []string{"hello"})},
		}
		mockRun := mockey.Mock(embedding.RunAll).Return(nil).Build()
		defer mockRun.UnPatch()
		mockAcc := mockey.Mock(accumulateBM25Stats).Return(fmt.Errorf("stats failed")).Build()
		defer mockAcc.UnPatch()
		rows, err := streamBatches(context.Background(), schema, executionSchema, nil, nil,
			requiredFields, reader, writer, nil, "cluster")
		s.Error(err)
		s.Equal(int64(0), rows)
	})

	s.T().Run("write error", func(t *testing.T) {
		reader := &fakeRecordReader{
			records: []storage.Record{s.makeStringStorageRecord(100, "text_col", []string{"hello"})},
		}
		mockRun := mockey.Mock(embedding.RunAll).Return(nil).Build()
		defer mockRun.UnPatch()
		mockAcc := mockey.Mock(accumulateBM25Stats).Return(nil).Build()
		defer mockAcc.UnPatch()
		mockWrite := mockey.Mock(writeOutputBatch).Return(fmt.Errorf("write failed")).Build()
		defer mockWrite.UnPatch()
		rows, err := streamBatches(context.Background(), schema, executionSchema, nil, nil,
			requiredFields, reader, writer, nil, "cluster")
		s.Error(err)
		s.Equal(int64(0), rows)
	})
}

func (s *RefreshExternalCollectionTaskSuite) TestExecuteFunctionsForSegmentSuccess() {
	ctx := context.Background()
	storageConfig := &indexpb.StorageConfig{RootPath: "files", StorageType: "local"}
	schema := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
			{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector, IsFunctionOutput: true},
			{FieldID: 102, Name: "vec", DataType: schemapb.DataType_FloatVector, ExternalField: "vec_col", TypeParams: []*commonpb.KeyValuePair{{Key: "dim", Value: "2"}}},
		},
		Functions: []*schemapb.FunctionSchema{
			{Type: schemapb.FunctionType_BM25, InputFieldIds: []int64{100}, OutputFieldIds: []int64{101}},
		},
	}
	reader := &fakeRecordReader{}
	writer := &packed.FFIPackedWriter{}
	basePath := "files/insert_log/1000/2000/3000"

	var sourceColumns []string
	mockCreate := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(
			ctx context.Context,
			basePath string,
			format string,
			columns []string,
			fragments []packed.Fragment,
			storageConfig *indexpb.StorageConfig,
			extfs packed.ExternalSpecContext,
		) (string, error) {
			sourceColumns = columns
			return packed.MarshalManifestPath(basePath, 42), nil
		}).Build()
	defer mockCreate.UnPatch()
	var gotInputSchema *schemapb.CollectionSchema
	mockOpen := mockey.Mock(openInputReader).
		To(func(
			ctx context.Context,
			schema *schemapb.CollectionSchema,
			manifestPath string,
			inputSchema *schemapb.CollectionSchema,
			storageConfig *indexpb.StorageConfig,
			collectionID int64,
		) (storage.RecordReader, error) {
			gotInputSchema = inputSchema
			return reader, nil
		}).Build()
	defer mockOpen.UnPatch()
	mockNewWriter := mockey.Mock(packed.NewFFIPackedWriter).
		To(func(
			basePath string,
			schema *arrow.Schema,
			columnGroups []storagecommon.ColumnGroup,
			storageConfig *indexpb.StorageConfig,
			storagePluginContext *indexcgopb.StoragePluginContext,
			extraProperties ...map[string]string,
		) (*packed.FFIPackedWriter, error) {
			s.Require().NotNil(schema)
			s.Equal("101", schema.Field(0).Name)
			s.Require().Len(columnGroups, 1)
			s.Equal([]int{0}, columnGroups[0].Columns)
			return writer, nil
		}).Build()
	defer mockNewWriter.UnPatch()
	mockStream := mockey.Mock(streamBatches).Return(int64(7), nil).Build()
	defer mockStream.UnPatch()
	output := new(packed.ColumnGroups)
	mockClose := mockey.Mock(mockey.GetMethod(writer, "Close")).Return(output, nil).Build()
	defer mockClose.UnPatch()
	mockAppend := mockey.Mock(appendBM25Stats).Return(nil).Build()
	defer mockAppend.UnPatch()
	mockCommit := mockey.Mock(packed.CommitManifestUpdates).
		To(func(basePath string, version int64, storageConfig *indexpb.StorageConfig, updates *packed.ManifestUpdates) (string, error) {
			s.Equal(int64(42), version)
			s.Same(output, updates.NewFiles)
			return "final-manifest", nil
		}).Build()
	defer mockCommit.UnPatch()

	manifestPath, err := ExecuteFunctionsForSegment(
		ctx,
		schema,
		[]packed.Fragment{{FragmentID: 1}},
		"parquet",
		storageConfig,
		s.collectionID,
		3000,
		basePath,
		"cluster",
	)
	s.NoError(err)
	s.Equal("final-manifest", manifestPath)
	s.Equal([]string{"text_col", "vec_col"}, sourceColumns)
	s.Require().NotNil(gotInputSchema)
	s.Require().Len(gotInputSchema.GetFields(), 1)
	s.Equal(int64(100), gotInputSchema.GetFields()[0].GetFieldID())
}

func (s *RefreshExternalCollectionTaskSuite) TestExecuteFunctionsForSegmentErrorPaths() {
	ctx := context.Background()
	storageConfig := &indexpb.StorageConfig{RootPath: "files", StorageType: "local"}
	basePath := "files/insert_log/1000/2000/3000"
	schema := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
			{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector, IsFunctionOutput: true},
		},
		Functions: []*schemapb.FunctionSchema{
			{Type: schemapb.FunctionType_BM25, InputFieldIds: []int64{100}, OutputFieldIds: []int64{101}},
		},
	}

	s.T().Run("parse input manifest", func(t *testing.T) {
		mockCreate := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).Return("bad manifest", nil).Build()
		defer mockCreate.UnPatch()
		manifestPath, err := ExecuteFunctionsForSegment(ctx, schema, nil, "parquet", storageConfig, s.collectionID, 3000, basePath, "cluster")
		s.Error(err)
		s.Empty(manifestPath)
	})

	s.T().Run("output schema conversion", func(t *testing.T) {
		badSchema := &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 101, Name: "bad", DataType: schemapb.DataType_None, IsFunctionOutput: true},
			},
		}
		mockCreate := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).Return(packed.MarshalManifestPath(basePath, 42), nil).Build()
		defer mockCreate.UnPatch()
		manifestPath, err := ExecuteFunctionsForSegment(ctx, badSchema, nil, "parquet", storageConfig, s.collectionID, 3000, basePath, "cluster")
		s.Error(err)
		s.Empty(manifestPath)
	})

	s.T().Run("execution schema", func(t *testing.T) {
		badSchema := &schemapb.CollectionSchema{
			ExternalSource: "s3://bucket/path",
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar},
				{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector, IsFunctionOutput: true},
			},
			Functions: []*schemapb.FunctionSchema{
				{Type: schemapb.FunctionType_BM25, InputFieldIds: []int64{100}, OutputFieldIds: []int64{101}},
			},
		}
		mockCreate := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).Return(packed.MarshalManifestPath(basePath, 42), nil).Build()
		defer mockCreate.UnPatch()
		manifestPath, err := ExecuteFunctionsForSegment(ctx, badSchema, nil, "parquet", storageConfig, s.collectionID, 3000, basePath, "cluster")
		s.Error(err)
		s.Empty(manifestPath)
		s.Contains(err.Error(), "has no external_field")
	})

	s.T().Run("open reader", func(t *testing.T) {
		mockCreate := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).Return(packed.MarshalManifestPath(basePath, 42), nil).Build()
		defer mockCreate.UnPatch()
		mockOpen := mockey.Mock(openInputReader).Return(nil, fmt.Errorf("open failed")).Build()
		defer mockOpen.UnPatch()
		manifestPath, err := ExecuteFunctionsForSegment(ctx, schema, nil, "parquet", storageConfig, s.collectionID, 3000, basePath, "cluster")
		s.Error(err)
		s.Empty(manifestPath)
	})

	s.T().Run("new writer", func(t *testing.T) {
		reader := &fakeRecordReader{}
		mockCreate := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).Return(packed.MarshalManifestPath(basePath, 42), nil).Build()
		defer mockCreate.UnPatch()
		mockOpen := mockey.Mock(openInputReader).Return(reader, nil).Build()
		defer mockOpen.UnPatch()
		mockWriter := mockey.Mock(packed.NewFFIPackedWriter).Return(nil, fmt.Errorf("writer failed")).Build()
		defer mockWriter.UnPatch()
		manifestPath, err := ExecuteFunctionsForSegment(ctx, schema, nil, "parquet", storageConfig, s.collectionID, 3000, basePath, "cluster")
		s.Error(err)
		s.Empty(manifestPath)
	})

	s.T().Run("stream", func(t *testing.T) {
		reader := &fakeRecordReader{}
		writer := &packed.FFIPackedWriter{}
		mockCreate := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).Return(packed.MarshalManifestPath(basePath, 42), nil).Build()
		defer mockCreate.UnPatch()
		mockOpen := mockey.Mock(openInputReader).Return(reader, nil).Build()
		defer mockOpen.UnPatch()
		mockWriter := mockey.Mock(packed.NewFFIPackedWriter).Return(writer, nil).Build()
		defer mockWriter.UnPatch()
		mockStream := mockey.Mock(streamBatches).Return(int64(0), fmt.Errorf("stream failed")).Build()
		defer mockStream.UnPatch()
		manifestPath, err := ExecuteFunctionsForSegment(ctx, schema, nil, "parquet", storageConfig, s.collectionID, 3000, basePath, "cluster")
		s.Error(err)
		s.Empty(manifestPath)
	})

	s.T().Run("close writer", func(t *testing.T) {
		reader := &fakeRecordReader{}
		writer := &packed.FFIPackedWriter{}
		mockCreate := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).Return(packed.MarshalManifestPath(basePath, 42), nil).Build()
		defer mockCreate.UnPatch()
		mockOpen := mockey.Mock(openInputReader).Return(reader, nil).Build()
		defer mockOpen.UnPatch()
		mockWriter := mockey.Mock(packed.NewFFIPackedWriter).Return(writer, nil).Build()
		defer mockWriter.UnPatch()
		mockStream := mockey.Mock(streamBatches).Return(int64(1), nil).Build()
		defer mockStream.UnPatch()
		mockClose := mockey.Mock(mockey.GetMethod(writer, "Close")).Return(nil, fmt.Errorf("close failed")).Build()
		defer mockClose.UnPatch()
		manifestPath, err := ExecuteFunctionsForSegment(ctx, schema, nil, "parquet", storageConfig, s.collectionID, 3000, basePath, "cluster")
		s.Error(err)
		s.Empty(manifestPath)
	})

	s.T().Run("finalize stats", func(t *testing.T) {
		reader := &fakeRecordReader{}
		writer := &packed.FFIPackedWriter{}
		mockCreate := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).Return(packed.MarshalManifestPath(basePath, 42), nil).Build()
		defer mockCreate.UnPatch()
		mockOpen := mockey.Mock(openInputReader).Return(reader, nil).Build()
		defer mockOpen.UnPatch()
		mockWriter := mockey.Mock(packed.NewFFIPackedWriter).Return(writer, nil).Build()
		defer mockWriter.UnPatch()
		mockStream := mockey.Mock(streamBatches).Return(int64(1), nil).Build()
		defer mockStream.UnPatch()
		mockClose := mockey.Mock(mockey.GetMethod(writer, "Close")).Return(new(packed.ColumnGroups), nil).Build()
		defer mockClose.UnPatch()
		mockAppend := mockey.Mock(appendBM25Stats).Return(fmt.Errorf("finalize failed")).Build()
		defer mockAppend.UnPatch()
		manifestPath, err := ExecuteFunctionsForSegment(ctx, schema, nil, "parquet", storageConfig, s.collectionID, 3000, basePath, "cluster")
		s.Error(err)
		s.Empty(manifestPath)
	})
}

func (s *RefreshExternalCollectionTaskSuite) TestTaskAccessorsAndCloneHelpers() {
	var nilCtx context.Context
	s.NoError(ensureContext(nilCtx))
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	s.Error(ensureContext(ctx))

	taskCtx := context.Background()
	task := s.newTask(taskCtx, &datapb.RefreshExternalCollectionTaskRequest{TaskID: 10})
	task.updatedSegments = []*datapb.SegmentInfo{{ID: 100}}
	s.Equal(taskCtx, task.Ctx())
	s.Equal(task.updatedSegments, task.GetUpdatedSegments())

	segments := []*datapb.SegmentInfo{{ID: 1}, nil, {ID: 2}}
	cloned := cloneSegments(segments)
	s.Require().Len(cloned, 3)
	s.Equal(int64(1), cloned[0].GetID())
	s.Nil(cloned[1])
	s.NotSame(segments[0], cloned[0])
	cloned[0].ID = 10
	s.Equal(int64(1), segments[0].GetID())

	info := &TaskInfo{
		State:           indexpb.JobState_JobStateInProgress,
		CollID:          1000,
		KeptSegments:    []int64{1, 2},
		UpdatedSegments: []*datapb.SegmentInfo{{ID: 3}},
	}
	infoClone := info.Clone()
	s.Equal(info.KeptSegments, infoClone.KeptSegments)
	s.Equal(info.UpdatedSegments[0].GetID(), infoClone.UpdatedSegments[0].GetID())
	infoClone.KeptSegments[0] = 100
	infoClone.UpdatedSegments[0].ID = 300
	s.Equal(int64(1), info.KeptSegments[0])
	s.Equal(int64(3), info.UpdatedSegments[0].GetID())
}

func (s *RefreshExternalCollectionTaskSuite) TestExecuteWithMockedSteps() {
	ctx := context.Background()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 3000, End: 4000},
		CurrentSegments:        []*datapb.SegmentInfo{{ID: 1}},
		ExploreManifestPath:    "manifest",
		StorageConfig:          &indexpb.StorageConfig{RootPath: "files", StorageType: "local"},
		Schema:                 &schemapb.CollectionSchema{Fields: []*schemapb.FieldSchema{{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar}}},
		ExternalSource:         "s3://bucket/path",
		ExternalSpec:           `{"format":"parquet"}`,
		NumSegmentsExpected:    1,
		FileIndexBegin:         0,
		FileIndexEnd:           1,
	}
	task := s.newTask(ctx, req)
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}
	task.columns = []string{"text"}
	fragments := []packed.Fragment{{FragmentID: 1, FilePath: "file", StartRow: 0, EndRow: 10, RowCount: 10}}
	segmentFragments := packed.SegmentFragments{1: fragments}
	updated := []*datapb.SegmentInfo{{ID: 1}}

	mockFetch := mockey.Mock(mockey.GetMethod(task, "fetchFragmentsFromExternalSource")).Return(fragments, nil).Build()
	defer mockFetch.UnPatch()
	mockBuild := mockey.Mock(mockey.GetMethod(task, "buildCurrentSegmentFragments")).Return(segmentFragments, nil).Build()
	defer mockBuild.UnPatch()
	mockOrganize := mockey.Mock(mockey.GetMethod(task, "organizeSegments")).
		To(func(ctx context.Context, currentSegmentFragments packed.SegmentFragments, newFragments []packed.Fragment) ([]*datapb.SegmentInfo, error) {
			task.updatedSegments = updated
			return updated, nil
		}).Build()
	defer mockOrganize.UnPatch()

	err := task.Execute(ctx)
	s.NoError(err)
	s.Equal(req.GetPreAllocatedSegmentIds(), task.preallocatedIDRange)
	s.Equal(int64(3000), task.nextAllocID)
	s.Equal(updated, task.GetUpdatedSegments())
}

func (s *RefreshExternalCollectionTaskSuite) TestExecuteErrorPathsWithMockedSteps() {
	ctx := context.Background()
	newTask := func() *RefreshExternalCollectionTask {
		req := &datapb.RefreshExternalCollectionTaskRequest{
			CollectionID:           s.collectionID,
			TaskID:                 s.taskID,
			PreAllocatedSegmentIds: &datapb.IDRange{Begin: 3000, End: 4000},
		}
		return s.newTask(ctx, req)
	}

	task := s.newTask(ctx, &datapb.RefreshExternalCollectionTaskRequest{TaskID: s.taskID})
	s.Error(task.Execute(ctx))

	task = newTask()
	mockFetch := mockey.Mock(mockey.GetMethod(task, "fetchFragmentsFromExternalSource")).Return(nil, fmt.Errorf("fetch failed")).Build()
	err := task.Execute(ctx)
	s.Error(err)
	s.Contains(err.Error(), "failed to fetch fragments")
	mockFetch.UnPatch()

	task = newTask()
	fragments := []packed.Fragment{{FragmentID: 1}}
	mockFetch = mockey.Mock(mockey.GetMethod(task, "fetchFragmentsFromExternalSource")).Return(fragments, nil).Build()
	mockBuild := mockey.Mock(mockey.GetMethod(task, "buildCurrentSegmentFragments")).Return(nil, fmt.Errorf("build failed")).Build()
	err = task.Execute(ctx)
	s.Error(err)
	s.Contains(err.Error(), "failed to build current segment fragments")
	mockBuild.UnPatch()
	mockFetch.UnPatch()

	task = newTask()
	mockFetch = mockey.Mock(mockey.GetMethod(task, "fetchFragmentsFromExternalSource")).Return(fragments, nil).Build()
	mockBuild = mockey.Mock(mockey.GetMethod(task, "buildCurrentSegmentFragments")).Return(packed.SegmentFragments{}, nil).Build()
	mockOrganize := mockey.Mock(mockey.GetMethod(task, "organizeSegments")).Return(nil, fmt.Errorf("organize failed")).Build()
	err = task.Execute(ctx)
	s.Error(err)
	s.Contains(err.Error(), "organize failed")
	mockOrganize.UnPatch()
	mockBuild.UnPatch()
	mockFetch.UnPatch()
}

func (s *RefreshExternalCollectionTaskSuite) TestPostExecuteAndOrganizeCanceled() {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	task := s.newTask(ctx, &datapb.RefreshExternalCollectionTaskRequest{
		TaskID:       s.taskID,
		CollectionID: s.collectionID,
	})
	s.Error(task.PostExecute(ctx))

	segments, err := task.organizeSegments(ctx, nil, nil)
	s.Error(err)
	s.Nil(segments)

	segments, err = task.balanceFragmentsToSegments(ctx, []packed.Fragment{{FragmentID: 1, RowCount: 1}})
	s.Error(err)
	s.Nil(segments)
}

func (s *RefreshExternalCollectionTaskSuite) TestBuildCurrentSegmentFragments() {
	ctx := context.Background()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		StorageConfig:   &indexpb.StorageConfig{RootPath: "files", StorageType: "local"},
		CurrentSegments: []*datapb.SegmentInfo{{ID: 1}},
	}
	task := s.newTask(ctx, req)
	task.columns = []string{"text_col"}
	expected := packed.SegmentFragments{1: []packed.Fragment{{FragmentID: 1}}}
	var gotColumns []string
	mockBuild := mockey.Mock(packed.BuildCurrentSegmentFragments).
		To(func(
			segments []*datapb.SegmentInfo,
			storageConfig *indexpb.StorageConfig,
			columns []string,
		) (packed.SegmentFragments, error) {
			gotColumns = columns
			return expected, nil
		}).Build()
	defer mockBuild.UnPatch()

	got, err := task.buildCurrentSegmentFragments()
	s.NoError(err)
	s.Equal(expected, got)
	s.Equal([]string{"text_col"}, gotColumns)
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegmentsWithFunctions() {
	paramtable.Init()

	ctx := context.Background()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		PartitionID:            2000,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 3000, End: 4000},
		StorageConfig:          &indexpb.StorageConfig{RootPath: "files", StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
				{FieldID: 101, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector, IsFunctionOutput: true},
			},
			Functions: []*schemapb.FunctionSchema{
				{Type: schemapb.FunctionType_BM25, InputFieldIds: []int64{100}, OutputFieldIds: []int64{101}},
			},
		},
	}
	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	var gotBasePath string
	mockExec := mockey.Mock(ExecuteFunctionsForSegment).
		To(func(
			ctx context.Context,
			schema *schemapb.CollectionSchema,
			fragments []packed.Fragment,
			format string,
			storageConfig *indexpb.StorageConfig,
			collectionID int64,
			segmentID int64,
			basePath string,
			clusterID string,
		) (string, error) {
			gotBasePath = basePath
			return packed.MarshalManifestPath(basePath, 42), nil
		}).Build()
	defer mockExec.UnPatch()
	mockSample := mockey.Mock(packed.SampleExternalFieldSizes).
		Return(map[string]int64{"text_col": 64}, nil).Build()
	defer mockSample.UnPatch()

	result, err := task.balanceFragmentsToSegments(ctx, []packed.Fragment{{FragmentID: 1, RowCount: 10}})
	s.NoError(err)
	s.Require().Len(result, 1)
	s.Equal("files/insert_log/1000/2000/3000", gotBasePath)
	s.Equal(int64(3000), result[0].GetID())
	s.NotZero(result[0].GetBinlogs()[0].GetBinlogs()[0].GetMemorySize())
}

func (s *RefreshExternalCollectionTaskSuite) makeStringRecord(name string, values []string) arrow.Record {
	arrowSchema := arrow.NewSchema([]arrow.Field{{Name: name, Type: arrow.BinaryTypes.String}}, nil)
	builder := array.NewRecordBuilder(memory.DefaultAllocator, arrowSchema)
	defer builder.Release()
	stringBuilder := builder.Field(0).(*array.StringBuilder)
	stringBuilder.AppendValues(values, nil)
	return builder.NewRecord()
}

func (s *RefreshExternalCollectionTaskSuite) makeStringStorageRecord(fieldID int64, name string, values []string) storage.Record {
	return storage.NewSimpleArrowRecord(s.makeStringRecord(name, values), map[storage.FieldID]int{
		fieldID: 0,
	})
}

func (s *RefreshExternalCollectionTaskSuite) TestPreAllocatedSegmentIDs() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// Create request with pre-allocated IDs
	idRange := &datapb.IDRange{
		Begin: 1000,
		End:   2000,
	}

	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: idRange,
		NumSegmentsExpected:    1000,
		CurrentSegments:        []*datapb.SegmentInfo{},
	}

	task := s.newTask(ctx, req)

	// Before Execute(), pre-allocated fields should not be initialized
	s.Nil(task.preallocatedIDRange)
	s.Equal(int64(0), task.nextAllocID)

	// After testing with pre-allocated IDs manually set (simulating Execute)
	task.preallocatedIDRange = idRange
	task.nextAllocID = idRange.Begin

	// Verify pre-allocated IDs are properly initialized
	s.NotNil(task.preallocatedIDRange)
	s.Equal(int64(1000), task.preallocatedIDRange.Begin)
	s.Equal(int64(2000), task.preallocatedIDRange.End)
	s.Equal(int64(1000), task.nextAllocID)
}

func (s *RefreshExternalCollectionTaskSuite) TestPreAllocatedIDAllocation() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// Create request with pre-allocated IDs
	idRange := &datapb.IDRange{
		Begin: 5000,
		End:   5010, // Only 10 IDs available
	}

	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: idRange,
		NumSegmentsExpected:    10,
		CurrentSegments:        []*datapb.SegmentInfo{},
	}

	task := s.newTask(ctx, req)

	// Manually initialize (simulating Execute)
	task.preallocatedIDRange = idRange
	task.nextAllocID = idRange.Begin

	// Simulate creating multiple segments by advancing nextAllocID
	// This should work fine up to the limit
	for i := 0; i < 10; i++ {
		s.True(task.nextAllocID < task.preallocatedIDRange.End, "Should have IDs available")
		task.nextAllocID++
	}

	// Now we should be out of IDs
	s.Equal(task.preallocatedIDRange.End, task.nextAllocID)
}

func (s *RefreshExternalCollectionTaskSuite) TestMissingPreAllocatedIDs() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// Create request WITHOUT pre-allocated IDs
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:    s.collectionID,
		TaskID:          s.taskID,
		CurrentSegments: []*datapb.SegmentInfo{},
		// PreAllocatedSegmentIds is nil
	}

	task := s.newTask(ctx, req)

	// Execute should fail because pre-allocated IDs are missing
	err := task.Execute(ctx)
	s.NotNil(err)
	s.Contains(err.Error(), "pre-allocated segment IDs not provided")
}

func (s *RefreshExternalCollectionTaskSuite) TestPreExecute_NilSchema() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:   s.collectionID,
		TaskID:         s.taskID,
		ExternalSource: "test_source",
		StorageConfig:  &indexpb.StorageConfig{StorageType: "local"},
		// Schema is nil
	}
	task := s.newTask(ctx, req)
	err := task.PreExecute(ctx)
	s.Error(err)
	s.Contains(err.Error(), "schema is nil")
}

func (s *RefreshExternalCollectionTaskSuite) TestPreExecute_NilStorageConfig() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:   s.collectionID,
		TaskID:         s.taskID,
		ExternalSource: "test_source",
		Schema:         &schemapb.CollectionSchema{},
		// StorageConfig is nil
	}
	task := s.newTask(ctx, req)
	err := task.PreExecute(ctx)
	s.Error(err)
	s.Contains(err.Error(), "storage config is nil")
}

func (s *RefreshExternalCollectionTaskSuite) TestPreExecute_EmptyExternalSource() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:  s.collectionID,
		TaskID:        s.taskID,
		Schema:        &schemapb.CollectionSchema{},
		StorageConfig: &indexpb.StorageConfig{StorageType: "local"},
		// ExternalSource is empty
	}
	task := s.newTask(ctx, req)
	err := task.PreExecute(ctx)
	s.Error(err)
	s.Contains(err.Error(), "external source is empty")
}

func (s *RefreshExternalCollectionTaskSuite) TestPreExecute_InvalidExternalSpec() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:   s.collectionID,
		TaskID:         s.taskID,
		ExternalSource: "test_source",
		ExternalSpec:   "not-valid-json",
		Schema:         &schemapb.CollectionSchema{},
		StorageConfig:  &indexpb.StorageConfig{StorageType: "local"},
	}
	task := s.newTask(ctx, req)
	err := task.PreExecute(ctx)
	s.Error(err)
	s.Contains(err.Error(), "failed to parse external spec")
}

func (s *RefreshExternalCollectionTaskSuite) TestPreExecute_UnsupportedFormat() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:   s.collectionID,
		TaskID:         s.taskID,
		ExternalSource: "test_source",
		ExternalSpec:   `{"format":"avro"}`,
		Schema:         &schemapb.CollectionSchema{},
		StorageConfig:  &indexpb.StorageConfig{StorageType: "local"},
	}
	task := s.newTask(ctx, req)
	err := task.PreExecute(ctx)
	s.Error(err)
	s.Contains(err.Error(), "unsupported format")
}

func (s *RefreshExternalCollectionTaskSuite) TestParseExternalSpec() {
	// Empty spec defaults to parquet
	spec, err := externalspec.ParseExternalSpec("")
	s.NoError(err)
	s.Equal("parquet", spec.Format)

	// Lance-table format
	spec, err = externalspec.ParseExternalSpec(`{"format":"lance-table"}`)
	s.NoError(err)
	s.Equal("lance-table", spec.Format)

	// Vortex format
	spec, err = externalspec.ParseExternalSpec(`{"format":"vortex"}`)
	s.NoError(err)
	s.Equal("vortex", spec.Format)

	// Missing format defaults to parquet
	spec, err = externalspec.ParseExternalSpec(`{}`)
	s.NoError(err)
	s.Equal("parquet", spec.Format)

	// Invalid JSON
	_, err = externalspec.ParseExternalSpec("not-json")
	s.Error(err)

	// Unsupported format
	_, err = externalspec.ParseExternalSpec(`{"format":"avro"}`)
	s.Error(err)
	s.Contains(err.Error(), "unsupported format")
}

func (s *RefreshExternalCollectionTaskSuite) TestParseExternalSpec_ExtfsWhitelist() {
	// Valid extfs keys
	spec, err := externalspec.ParseExternalSpec(`{"format":"parquet","extfs":{"use_iam":"true","region":"us-west-2"}}`)
	s.NoError(err)
	s.Equal("true", spec.Extfs["use_iam"])
	s.Equal("us-west-2", spec.Extfs["region"])

	// All allowed keys should be accepted
	spec, err = externalspec.ParseExternalSpec(`{"format":"parquet","extfs":{"use_ssl":"false","use_virtual_host":"false","cloud_provider":"aws","iam_endpoint":"https://sts.amazonaws.com","storage_type":"remote","ssl_ca_cert":"cert"}}`)
	s.NoError(err)
	s.Len(spec.Extfs, 6)

	// Credential keys are now allowed for cross-bucket scenarios
	spec, err = externalspec.ParseExternalSpec(`{"format":"parquet","extfs":{"access_key_id":"AKIA..."}}`)
	s.NoError(err)
	s.Equal("AKIA...", spec.Extfs["access_key_id"])

	spec, err = externalspec.ParseExternalSpec(`{"format":"parquet","extfs":{"access_key_value":"secret"}}`)
	s.NoError(err)
	s.Equal("secret", spec.Extfs["access_key_value"])

	// Unknown key
	_, err = externalspec.ParseExternalSpec(`{"format":"parquet","extfs":{"unknown_key":"val"}}`)
	s.Error(err)
	s.Contains(err.Error(), "not allowed")

	// Empty extfs is fine
	spec, err = externalspec.ParseExternalSpec(`{"format":"parquet","extfs":{}}`)
	s.NoError(err)
	s.Empty(spec.Extfs)

	// No extfs field is fine
	spec, err = externalspec.ParseExternalSpec(`{"format":"parquet"}`)
	s.NoError(err)
	s.Nil(spec.Extfs)
}

func (s *RefreshExternalCollectionTaskSuite) TestParseExternalSpec_ExtfsBooleanValidation() {
	// Valid boolean values
	_, err := externalspec.ParseExternalSpec(`{"format":"parquet","extfs":{"use_iam":"true"}}`)
	s.NoError(err)
	_, err = externalspec.ParseExternalSpec(`{"format":"parquet","extfs":{"use_ssl":"false"}}`)
	s.NoError(err)

	// Invalid boolean values
	_, err = externalspec.ParseExternalSpec(`{"format":"parquet","extfs":{"use_iam":"maybe"}}`)
	s.Error(err)
	s.Contains(err.Error(), "must be \"true\" or \"false\"")

	_, err = externalspec.ParseExternalSpec(`{"format":"parquet","extfs":{"use_ssl":"1"}}`)
	s.Error(err)

	_, err = externalspec.ParseExternalSpec(`{"format":"parquet","extfs":{"use_virtual_host":"yes"}}`)
	s.Error(err)

	// Non-boolean keys accept any string value
	_, err = externalspec.ParseExternalSpec(`{"format":"parquet","extfs":{"region":"any-value-is-ok"}}`)
	s.NoError(err)
}

func (s *RefreshExternalCollectionTaskSuite) TestFragmentKey() {
	f1 := packed.Fragment{FilePath: "/data/file1.parquet", StartRow: 0, EndRow: 1000}
	f2 := packed.Fragment{FilePath: "/data/file1.parquet", StartRow: 1000, EndRow: 2000}
	f3 := packed.Fragment{FilePath: "/data/file2.parquet", StartRow: 0, EndRow: 1000}

	// Same file, different ranges should produce different keys
	s.NotEqual(fragmentKey(f1), fragmentKey(f2))

	// Different files, same ranges should produce different keys
	s.NotEqual(fragmentKey(f1), fragmentKey(f3))

	// Identical fragments should produce the same key
	f4 := packed.Fragment{FilePath: "/data/file1.parquet", StartRow: 0, EndRow: 1000}
	s.Equal(fragmentKey(f1), fragmentKey(f4))

	// L0 deltalogs are not part of the L1 data identity. They are handled by a
	// manifest-only refresh so the target segment ID can be reused.
	f5 := packed.Fragment{
		FilePath: "/data/file1.parquet",
		StartRow: 0,
		EndRow:   1000,
		Deltalogs: []*datapb.FieldBinlog{{
			FieldID: 100,
			Binlogs: []*datapb.Binlog{{
				LogID:      10,
				LogPath:    "s3://bucket/files/insert_log/1/_delta/10",
				EntriesNum: 2,
			}},
		}},
	}
	f6 := packed.Fragment{
		FilePath: "/data/file1.parquet",
		StartRow: 0,
		EndRow:   1000,
		Deltalogs: []*datapb.FieldBinlog{{
			FieldID: 100,
			Binlogs: []*datapb.Binlog{{
				LogID:      11,
				LogPath:    "s3://bucket/files/insert_log/1/_delta/11",
				EntriesNum: 2,
			}},
		}},
	}
	s.Equal(fragmentKey(f1), fragmentKey(f5))
	s.Equal(fragmentKey(f5), fragmentKey(f6))
}

func (s *RefreshExternalCollectionTaskSuite) TestGetColumnNamesFromSchema() {
	// Nil schema
	columns := packed.GetColumnNamesFromSchema(nil)
	s.Nil(columns)

	// Schema with ExternalField set
	schema := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{Name: "id", ExternalField: "external_id"},
			{Name: "vector", ExternalField: "embedding"},
		},
	}
	columns = packed.GetColumnNamesFromSchema(schema)
	s.Equal([]string{"external_id", "embedding"}, columns)

	// Schema without ExternalField (fallback to field name)
	schema2 := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{Name: "id"},
			{Name: "vector"},
		},
	}
	columns = packed.GetColumnNamesFromSchema(schema2)
	s.Equal([]string{"id", "vector"}, columns)

	// Mixed: some with ExternalField, some without
	schema3 := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{Name: "id", ExternalField: "external_id"},
			{Name: "vector"},
		},
	}
	columns = packed.GetColumnNamesFromSchema(schema3)
	s.Equal([]string{"external_id", "vector"}, columns)
}

func (s *RefreshExternalCollectionTaskSuite) TestFetchFragmentsFromExternalSource_EmptyManifest() {
	ctx := context.Background()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:        s.collectionID,
		TaskID:              s.taskID,
		ExternalSource:      "s3:///bucket/path",
		ExternalSpec:        `{"format":"parquet"}`,
		ExploreManifestPath: "", // empty
	}
	task := s.newTask(ctx, req)
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	_, err := task.fetchFragmentsFromExternalSource(ctx)
	s.Error(err)
	s.Contains(err.Error(), "manifest path is required")
}

func (s *RefreshExternalCollectionTaskSuite) TestFetchFragmentsFromExternalSource_Success() {
	ctx := context.Background()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:         s.collectionID,
		TaskID:               s.taskID,
		ExternalSource:       "s3:///bucket/path",
		ExternalSpec:         `{"format":"parquet"}`,
		ExploreManifestPath:  "/manifests/explore.json",
		FileIndexBegin:       0,
		FileIndexEnd:         5,
		StorageConfig:        &indexpb.StorageConfig{StorageType: "local", BucketName: "/tmp"},
		TargetRowsPerSegment: 12345,
	}
	task := s.newTask(ctx, req)
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}
	task.columns = []string{"col1"}

	mockFetch := mockey.Mock(packed.FetchFragmentsFromExternalSourceWithRange).
		To(func(
			ctx context.Context,
			format string,
			columns []string,
			externalSource string,
			storageConfig *indexpb.StorageConfig,
			fileIndexBegin, fileIndexEnd int64,
			exploreManifestPath string,
			opts packed.ExternalFetchOptions,
		) ([]packed.Fragment, error) {
			s.Equal(req.GetTargetRowsPerSegment(), opts.RowLimit)
			return []packed.Fragment{
				{FragmentID: 0, FilePath: "f1.parquet", StartRow: 0, EndRow: 1000, RowCount: 1000},
			}, nil
		}).Build()
	defer mockFetch.UnPatch()

	frags, err := task.fetchFragmentsFromExternalSource(ctx)
	s.NoError(err)
	s.Len(frags, 1)
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_CreateManifestError() {
	paramtable.Init()
	ctx := context.Background()
	tmpDir := s.T().TempDir()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:   s.collectionID,
		ExternalSource: "s3:///bucket/path",
		ExternalSpec:   `{"format":"parquet"}`,
		StorageConfig:  &indexpb.StorageConfig{StorageType: "local", BucketName: tmpDir, RootPath: tmpDir},
	}
	task := s.newTask(ctx, req)
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}
	task.columns = []string{"col1"}
	task.preallocatedIDRange = &datapb.IDRange{Begin: 1, End: 100}
	task.nextAllocID = 1

	// Mock createManifestForSegment to return error
	mockCM := mockey.Mock((*RefreshExternalCollectionTask).createManifestForSegment).
		Return("", fmt.Errorf("create manifest failed")).Build()
	defer mockCM.UnPatch()

	fragments := []packed.Fragment{
		{FragmentID: 0, FilePath: "f.parquet", RowCount: 100, StartRow: 0, EndRow: 100},
	}
	_, err := task.balanceFragmentsToSegments(ctx, fragments)
	s.Error(err)
	s.Contains(err.Error(), "create manifest failed")
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_ContextCanceled() {
	paramtable.Init()
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	cancel()
	tmpDir := s.T().TempDir()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:   s.collectionID,
		ExternalSource: "s3:///bucket/path",
		ExternalSpec:   `{"format":"parquet"}`,
		StorageConfig:  &indexpb.StorageConfig{StorageType: "local", BucketName: tmpDir, RootPath: tmpDir},
	}
	task := s.newTask(ctx, req)
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}
	task.columns = []string{"col1"}
	task.preallocatedIDRange = &datapb.IDRange{Begin: 1, End: 100}
	task.nextAllocID = 1

	// createManifestForSegment should not be called because ctx is canceled
	mockCM := mockey.Mock((*RefreshExternalCollectionTask).createManifestForSegment).
		Return("/manifest", nil).Build()
	defer mockCM.UnPatch()

	fragments := []packed.Fragment{
		{FragmentID: 0, FilePath: "f.parquet", RowCount: 100, StartRow: 0, EndRow: 100},
	}
	_, err := task.balanceFragmentsToSegments(ctx, fragments)
	s.Error(err)
}

// TestBalanceFragmentsToSegments_CtxCancelledDuringManifest verifies the
// post-AwaitAll ctx.Err() guard: if workers finish without error (e.g. a mock
// that ignores ctx) but the context was canceled during the pool run, the
// function must still return ctx.Err() rather than build a result from the
// half-done work.
func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_CtxCancelledDuringManifest() {
	paramtable.Init()
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	tmpDir := s.T().TempDir()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:   s.collectionID,
		ExternalSource: "s3:///bucket/path",
		ExternalSpec:   `{"format":"parquet"}`,
		StorageConfig:  &indexpb.StorageConfig{StorageType: "local", BucketName: tmpDir, RootPath: tmpDir},
	}
	task := s.newTask(ctx, req)
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}
	task.columns = []string{"col1"}
	task.preallocatedIDRange = &datapb.IDRange{Begin: 1, End: 100}
	task.nextAllocID = 1

	// Mock createManifestForSegment to cancel the ctx mid-run and return nil.
	// Workers do not propagate an error, so AwaitAll returns nil; the
	// post-AwaitAll ctx.Err() check is the only thing that surfaces the cancel.
	mockCM := mockey.Mock((*RefreshExternalCollectionTask).createManifestForSegment).
		To(func(_ *RefreshExternalCollectionTask, _ context.Context, _ int64, _ []packed.Fragment) (string, error) {
			cancel()
			return "/manifest", nil
		}).Build()
	defer mockCM.UnPatch()

	fragments := []packed.Fragment{
		{FragmentID: 0, FilePath: "f.parquet", RowCount: 100, StartRow: 0, EndRow: 100},
	}
	_, err := task.balanceFragmentsToSegments(ctx, fragments)
	s.Require().Error(err)
	s.ErrorIs(err, context.Canceled)
}

func (s *RefreshExternalCollectionTaskSuite) TestBuildFakeBinlogs() {
	schema := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 1, Name: "pk"},
			{FieldID: 100, Name: "text"},
			{FieldID: 101, Name: "vec"},
		},
	}

	binlogs := buildFakeBinlogs(999, 1000, 512000, schema, "parquet")
	s.Len(binlogs, 1)
	fb := binlogs[0]
	s.Equal(int64(0), fb.GetFieldID(), "should use DefaultShortColumnGroupID=0")
	s.ElementsMatch([]int64{1, 100, 101}, fb.GetChildFields())
	s.Equal("parquet", fb.GetFormat())
	s.Len(fb.GetBinlogs(), 1)
	s.Equal(int64(999), fb.GetBinlogs()[0].GetLogID(), "logID should be segmentID")
	s.Equal(int64(1000), fb.GetBinlogs()[0].GetEntriesNum())
	s.Equal(int64(512000), fb.GetBinlogs()[0].GetMemorySize())
	s.Equal(int64(512000), fb.GetBinlogs()[0].GetLogSize())
}

func (s *RefreshExternalCollectionTaskSuite) TestBuildFakeBinlogs_NilSchema() {
	binlogs := buildFakeBinlogs(888, 500, 100000, nil, "vortex")
	s.Len(binlogs, 1)
	s.Empty(binlogs[0].GetChildFields())
	s.Equal("vortex", binlogs[0].GetFormat())
	s.Equal(int64(500), binlogs[0].GetBinlogs()[0].GetEntriesNum())
}

func (s *RefreshExternalCollectionTaskSuite) TestSumFieldSizes() {
	schema := &schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 1, Name: "pk"},                                    // no ExternalField
			{FieldID: 100, Name: "text", ExternalField: "text_col"},     // external
			{FieldID: 101, Name: "vec", ExternalField: "embedding_col"}, // external
		},
	}

	fieldSizes := map[string]int64{
		"text_col":      64,
		"embedding_col": 512,
		"extra_col":     999, // not in schema, should be ignored
	}

	total := sumFieldSizes(fieldSizes, schema)
	s.Equal(int64(576), total, "should sum only external fields: 64+512")
}

func (s *RefreshExternalCollectionTaskSuite) TestSumFieldSizes_MilvusTableUsesSourceFieldIDs() {
	schema := &schemapb.CollectionSchema{
		ExternalSpec: `{"format":"milvus-table"}`,
		Fields: []*schemapb.FieldSchema{
			{FieldID: 0, Name: common.VirtualPKFieldName},
			{FieldID: 100, Name: "target_pk", ExternalField: "pk"},
			{FieldID: 101, Name: "target_vec", ExternalField: "vec"},
		},
	}
	fieldSizes := map[string]int64{
		"100": 64,
		"101": 512,
	}

	total := sumFieldSizes(fieldSizes, schema)
	s.Equal(int64(576), total)
}

func (s *RefreshExternalCollectionTaskSuite) TestSumFieldSizes_MilvusTableSkipsFunctionOutputField() {
	schema := &schemapb.CollectionSchema{
		ExternalSpec: `{"format":"milvus-table"}`,
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", ExternalField: "text"},
			{FieldID: 101, Name: "vec", ExternalField: "vec"},
			{FieldID: 102, Name: "sparse", DataType: schemapb.DataType_SparseFloatVector, IsFunctionOutput: true},
		},
	}
	fieldSizes := map[string]int64{
		"100": 64,
		"101": 512,
		"102": 4096,
	}

	total := sumFieldSizes(fieldSizes, schema)
	s.Equal(int64(576), total)
}

func (s *RefreshExternalCollectionTaskSuite) TestSumFieldSizes_NilSchema() {
	fieldSizes := map[string]int64{"a": 10, "b": 20}
	total := sumFieldSizes(fieldSizes, nil)
	s.Equal(int64(30), total, "nil schema should sum all fields")
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_SamplingFails() {
	paramtable.Init()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", ExternalField: "text_col"},
			},
		},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	m1 := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(ctx context.Context, basePath, format string, columns []string, fragments []packed.Fragment, storageConfig *indexpb.StorageConfig, extfs packed.ExternalSpecContext) (string, error) {
			return fmt.Sprintf("%s/manifest.json", basePath), nil
		}).Build()
	defer m1.UnPatch()

	// Sampling fails on the only manifest. The previous behavior silently
	// emitted MemorySize=0 segments, which collapsed QueryNode's resource
	// estimator and risked OOM on load. Current behavior is to fail the
	// task so the task-level retry path can try again with a clean state.
	m2 := mockey.Mock(packed.SampleExternalFieldSizes).
		Return(nil, fmt.Errorf("sampling error")).Build()
	defer m2.UnPatch()

	fragments := []packed.Fragment{
		{FragmentID: 1, RowCount: 500},
	}

	result, err := task.balanceFragmentsToSegments(context.Background(), fragments)
	s.Error(err, "sampling failure must surface as a task error, not a silent MemorySize=0")
	s.Contains(err.Error(), "sampling failed")
	s.Nil(result)
}

// Regression for #48637: when external_field mappings point to columns
// absent in the parquet file, SampleExternalFieldSizes returns a real
// error like "Column 'wrong_col_a' not found in schema". That error
// must propagate to the task error (and thus to the RefreshFailed
// reason surfaced to the client) rather than being swallowed as a
// generic "sampling failed for all N segment(s)" message.
func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_SamplingError_RootCausePropagated() {
	paramtable.Init()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", ExternalField: "wrong_col_a"},
			},
		},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	m1 := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(ctx context.Context, basePath, format string, columns []string, fragments []packed.Fragment, storageConfig *indexpb.StorageConfig, extfs packed.ExternalSpecContext) (string, error) {
			return fmt.Sprintf("%s/manifest.json", basePath), nil
		}).Build()
	defer m1.UnPatch()

	cppErr := fmt.Errorf("Invalid: Column 'wrong_col_a' not found in schema. [path=data.parquet]")
	m2 := mockey.Mock(packed.SampleExternalFieldSizes).Return(nil, cppErr).Build()
	defer m2.UnPatch()

	fragments := []packed.Fragment{{FragmentID: 1, RowCount: 500}}

	result, err := task.balanceFragmentsToSegments(context.Background(), fragments)
	s.Error(err)
	s.Nil(result)
	// Root cause string must reach the client-facing task error.
	s.Contains(err.Error(), "Column 'wrong_col_a' not found in schema")
	// And emit actionable hint guiding the user to the real fix.
	s.Contains(err.Error(), "external_field mappings")
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_SamplingTypeMismatchFails() {
	paramtable.Init()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{
					FieldID:       100,
					Name:          "age",
					DataType:      schemapb.DataType_Int8,
					ExternalField: "age_col",
				},
			},
		},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	m1 := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(ctx context.Context, basePath, format string, columns []string, fragments []packed.Fragment, storageConfig *indexpb.StorageConfig, extfs packed.ExternalSpecContext) (string, error) {
			return fmt.Sprintf("%s/manifest.json", basePath), nil
		}).Build()
	defer m1.UnPatch()

	typeMismatchErr := fmt.Errorf("field type mismatch, expected Arrow int8, actual Arrow int64")
	m2 := mockey.Mock(packed.SampleExternalFieldSizes).Return(nil, typeMismatchErr).Build()
	defer m2.UnPatch()

	fragments := []packed.Fragment{{FragmentID: 1, RowCount: 500}}

	result, err := task.balanceFragmentsToSegments(context.Background(), fragments)
	s.Error(err)
	s.Nil(result)
	s.Contains(err.Error(), "sampling failed")
	s.Contains(err.Error(), "field type mismatch")
	s.Contains(err.Error(), "expected Arrow int8")
	s.Contains(err.Error(), "actual Arrow int64")
}

// Regression: the samplePerSegment=true branch of Phase 3 was previously
// uncovered by tests. This exercise is three parts:
//  1. All per-segment samples succeed → each segment gets its own
//     distinct MemorySize (proving per-segment semantics actually take
//     effect rather than collapsing to the first value).
//  2. First segment sample fails, later segments succeed → the zero-fill
//     pass retroactively backfills the first slot with the first
//     successful average (fallbackAvg).
//  3. All per-segment samples fail → task fails (no silent MemorySize=0).
func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_PerSegmentSampling_AllSucceed() {
	paramtable.Init()
	key := paramtable.Get().QueryNodeCfg.ExternalCollectionSamplePerSegment.Key
	paramtable.Get().Save(key, "true")
	defer paramtable.Get().Reset(key)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", ExternalField: "text_col"},
			},
		},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	m1 := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(ctx context.Context, basePath, format string, columns []string, fragments []packed.Fragment, storageConfig *indexpb.StorageConfig, extfs packed.ExternalSpecContext) (string, error) {
			return fmt.Sprintf("%s/manifest.json", basePath), nil
		}).Build()
	defer m1.UnPatch()

	// Return distinct sizes per call so we can prove per-segment samples
	// actually land in distinct slots.
	var callCount int32
	perCallSizes := []int64{50, 100, 200}
	m2 := mockey.Mock(packed.SampleExternalFieldSizes).
		To(func(manifestPath string, rows int, collectionID int64, externalSource, externalSpec string, schema *schemapb.CollectionSchema, storageConfig *indexpb.StorageConfig) (map[string]int64, error) {
			idx := int(atomic.AddInt32(&callCount, 1)) - 1
			if idx >= len(perCallSizes) {
				idx = len(perCallSizes) - 1
			}
			return map[string]int64{"text_col": perCallSizes[idx]}, nil
		}).Build()
	defer m2.UnPatch()

	// 3 fragments each above targetRowsPerSegment → 3 segments.
	targetRows := req.GetTargetRowsPerSegment()
	rowsPerFragment := targetRows * 2 // force one segment per fragment
	fragments := []packed.Fragment{
		{FragmentID: 1, RowCount: rowsPerFragment},
		{FragmentID: 2, RowCount: rowsPerFragment},
		{FragmentID: 3, RowCount: rowsPerFragment},
	}

	result, err := task.balanceFragmentsToSegments(context.Background(), fragments)
	s.NoError(err)
	s.Len(result, 3, "expected 3 segments for 3 oversized fragments")

	// Each segment's MemorySize reflects its own sample (size * rowCount).
	// SampleExternalFieldSizes is called per-segment in manifest order.
	for i, seg := range result {
		expectedSize := perCallSizes[i] * rowsPerFragment
		actual := seg.GetBinlogs()[0].GetBinlogs()[0].GetMemorySize()
		s.Equal(expectedSize, actual, "segment %d MemorySize should match its own sample", i)
	}
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_PerSegmentSampling_FirstFailBackfills() {
	paramtable.Init()
	key := paramtable.Get().QueryNodeCfg.ExternalCollectionSamplePerSegment.Key
	paramtable.Get().Save(key, "true")
	defer paramtable.Get().Reset(key)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", ExternalField: "text_col"},
			},
		},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	m1 := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(ctx context.Context, basePath, format string, columns []string, fragments []packed.Fragment, storageConfig *indexpb.StorageConfig, extfs packed.ExternalSpecContext) (string, error) {
			return fmt.Sprintf("%s/manifest.json", basePath), nil
		}).Build()
	defer m1.UnPatch()

	// First call fails; second and third succeed. Verify the first slot
	// is retroactively backfilled with the first successful average (150),
	// not the third (250), since fallbackAvg latches on the first success.
	var callCount int32
	m2 := mockey.Mock(packed.SampleExternalFieldSizes).
		To(func(manifestPath string, rows int, collectionID int64, externalSource, externalSpec string, schema *schemapb.CollectionSchema, storageConfig *indexpb.StorageConfig) (map[string]int64, error) {
			idx := int(atomic.AddInt32(&callCount, 1)) - 1
			switch idx {
			case 0:
				return nil, fmt.Errorf("transient sample failure")
			case 1:
				return map[string]int64{"text_col": 150}, nil
			default:
				return map[string]int64{"text_col": 250}, nil
			}
		}).Build()
	defer m2.UnPatch()

	targetRows := req.GetTargetRowsPerSegment()
	rowsPerFragment := targetRows * 2
	fragments := []packed.Fragment{
		{FragmentID: 1, RowCount: rowsPerFragment},
		{FragmentID: 2, RowCount: rowsPerFragment},
		{FragmentID: 3, RowCount: rowsPerFragment},
	}

	result, err := task.balanceFragmentsToSegments(context.Background(), fragments)
	s.NoError(err)
	s.Len(result, 3)

	// Expected: [150*rows, 150*rows, 250*rows]
	// - segment 0: backfilled from fallbackAvg=150
	// - segment 1: native sample 150
	// - segment 2: native sample 250
	s.Equal(int64(150)*rowsPerFragment, result[0].GetBinlogs()[0].GetBinlogs()[0].GetMemorySize(), "segment 0 should be backfilled from fallbackAvg")
	s.Equal(int64(150)*rowsPerFragment, result[1].GetBinlogs()[0].GetBinlogs()[0].GetMemorySize())
	s.Equal(int64(250)*rowsPerFragment, result[2].GetBinlogs()[0].GetBinlogs()[0].GetMemorySize())
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_PerSegmentSampling_AllFail() {
	paramtable.Init()
	key := paramtable.Get().QueryNodeCfg.ExternalCollectionSamplePerSegment.Key
	paramtable.Get().Save(key, "true")
	defer paramtable.Get().Reset(key)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", ExternalField: "text_col"},
			},
		},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	m1 := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(ctx context.Context, basePath, format string, columns []string, fragments []packed.Fragment, storageConfig *indexpb.StorageConfig, extfs packed.ExternalSpecContext) (string, error) {
			return fmt.Sprintf("%s/manifest.json", basePath), nil
		}).Build()
	defer m1.UnPatch()

	m2 := mockey.Mock(packed.SampleExternalFieldSizes).
		Return(nil, fmt.Errorf("all samples failed")).Build()
	defer m2.UnPatch()

	targetRows := req.GetTargetRowsPerSegment()
	rowsPerFragment := targetRows * 2
	fragments := []packed.Fragment{
		{FragmentID: 1, RowCount: rowsPerFragment},
		{FragmentID: 2, RowCount: rowsPerFragment},
	}

	result, err := task.balanceFragmentsToSegments(context.Background(), fragments)
	s.Error(err, "all per-segment samples failing must surface as a task error")
	s.Contains(err.Error(), "sampling failed")
	s.Nil(result)
}

// Regression: if the schema has no mapped external data fields, sumFieldSizes
// returns 0 even when SampleExternalFieldSizes itself "succeeds". The old
// code treated this as a successful zero-sized sample and wrote MemorySize=0
// fake binlogs into every segment, which feeds QueryNode a degenerate
// EstimatedBytesPerRow=0 and risks OOM on load. Current behavior: fail the
// task.
func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_ZeroSumTreatedAsFailure() {
	paramtable.Init()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				// Schema with NO ExternalField set — sumFieldSizes returns 0.
				{FieldID: 100, Name: "native_only"},
			},
		},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	m1 := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(ctx context.Context, basePath, format string, columns []string, fragments []packed.Fragment, storageConfig *indexpb.StorageConfig, extfs packed.ExternalSpecContext) (string, error) {
			return fmt.Sprintf("%s/manifest.json", basePath), nil
		}).Build()
	defer m1.UnPatch()

	// Sampling "succeeds" but returns sizes for a column the schema
	// doesn't reference — sumFieldSizes totals to 0.
	m2 := mockey.Mock(packed.SampleExternalFieldSizes).
		Return(map[string]int64{"orphan_col": 128}, nil).Build()
	defer m2.UnPatch()

	fragments := []packed.Fragment{
		{FragmentID: 1, RowCount: 500},
	}

	result, err := task.balanceFragmentsToSegments(context.Background(), fragments)
	s.Error(err, "zero-sum sample must surface as a task error")
	s.Contains(err.Error(), "sampling failed")
	s.Nil(result)
}

func (s *RefreshExternalCollectionTaskSuite) TestSumFieldSizes_MilvusTableRealPKIncludesSourceTimestamp() {
	schema := &schemapb.CollectionSchema{
		ExternalSpec: `{"format":"milvus-table"}`,
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "pk", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
			{FieldID: 101, Name: "vec", DataType: schemapb.DataType_FloatVector},
		},
	}

	total := sumFieldSizes(map[string]int64{
		"100": 16,
		"101": 64,
		"1":   8,
	}, schema)

	s.Equal(int64(88), total)
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_InsufficientIDs() {
	paramtable.Init()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 101}, // only 1 ID, need 2
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema:                 &schemapb.CollectionSchema{},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	fragments := []packed.Fragment{
		{FragmentID: 1, RowCount: 500},
	}

	_, err := task.balanceFragmentsToSegments(context.Background(), fragments)
	s.Error(err)
	s.Contains(err.Error(), "insufficient pre-allocated IDs")
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_ManifestCreateFails() {
	paramtable.Init()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema:                 &schemapb.CollectionSchema{},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	m1 := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		Return("", fmt.Errorf("storage error")).Build()
	defer m1.UnPatch()

	fragments := []packed.Fragment{
		{FragmentID: 1, RowCount: 500},
	}

	_, err := task.balanceFragmentsToSegments(context.Background(), fragments)
	s.Error(err)
	s.Contains(err.Error(), "failed to create manifest")
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_ContextCancelDuringManifest() {
	paramtable.Init()

	// Use a real cancelable context that we pass to balanceFragmentsToSegments
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema:                 &schemapb.CollectionSchema{},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	// Mock manifest creation: first call cancels ctx and returns error
	m1 := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(_ context.Context, basePath, format string, columns []string, fragments []packed.Fragment, storageConfig *indexpb.StorageConfig, extfs packed.ExternalSpecContext) (string, error) {
			cancel() // cancel the ctx we pass to balanceFragmentsToSegments
			return "", fmt.Errorf("canceled")
		}).Build()
	defer m1.UnPatch()

	// Use multiple fragments so there are multiple works — ctx cancel is checked
	// in the task dispatch loop (L643) and after wg.Wait (L657)
	fragments := []packed.Fragment{
		{FragmentID: 1, RowCount: 300},
		{FragmentID: 2, RowCount: 400},
		{FragmentID: 3, RowCount: 500},
	}

	// Pass the cancelable ctx so L643 `ctx.Err()` and L657 `ctx.Err()` can trigger
	_, err := task.balanceFragmentsToSegments(ctx, fragments)
	s.Error(err)
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_EnsureContextInLoop() {
	paramtable.Init()

	// Pre-cancel the context to trigger ensureContext inside for loops (L517, L558)
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema:                 &schemapb.CollectionSchema{},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	fragments := []packed.Fragment{
		{FragmentID: 1, RowCount: 100},
		{FragmentID: 2, RowCount: 200},
	}

	// Cancel immediately — the first ensureContext (L508) passes because it checks
	// before cancel. We need to cancel between L508 and L517.
	// Use a goroutine trick: mock ensureContext behavior isn't possible directly,
	// so just cancel and pass the canceled ctx.
	cancel()
	_, err := task.balanceFragmentsToSegments(ctx, fragments)
	s.ErrorIs(err, context.Canceled)
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_EmptyBinSkipped() {
	paramtable.Init()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				// Non-zero sample is required now that a zero sum is
				// treated as a sampling failure. The intent of this test
				// is the bin-skipping behavior, not the sampling path.
				{FieldID: 100, Name: "text", ExternalField: "text_col"},
			},
		},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	m1 := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(ctx context.Context, basePath, format string, columns []string, fragments []packed.Fragment, storageConfig *indexpb.StorageConfig, extfs packed.ExternalSpecContext) (string, error) {
			return fmt.Sprintf("%s/manifest.json", basePath), nil
		}).Build()
	defer m1.UnPatch()

	m2 := mockey.Mock(packed.SampleExternalFieldSizes).
		Return(map[string]int64{"text_col": 64}, nil).Build()
	defer m2.UnPatch()

	// One tiny fragment but targetRowsPerSegment is large — only 1 bin used, others empty
	fragments := []packed.Fragment{
		{FragmentID: 1, RowCount: 100},
	}

	result, err := task.balanceFragmentsToSegments(context.Background(), fragments)
	s.NoError(err)
	s.Len(result, 1)
	s.Equal(int64(100), result[0].GetNumOfRows())
	s.Equal(datapb.SegmentLevel_L1, result[0].GetLevel())
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_ContextCanceledInLoops() {
	paramtable.Init()

	testCases := []struct {
		name      string
		cancelAt  int
		fragments []packed.Fragment
	}{
		{
			name:      "total row loop",
			cancelAt:  2,
			fragments: []packed.Fragment{{FragmentID: 1, RowCount: 100}},
		},
		{
			name:      "bin packing loop",
			cancelAt:  4,
			fragments: []packed.Fragment{{FragmentID: 1, RowCount: 100}, {FragmentID: 2, RowCount: 200}},
		},
	}

	for _, tc := range testCases {
		s.T().Run(tc.name, func(t *testing.T) {
			ctx := context.Background()
			req := &datapb.RefreshExternalCollectionTaskRequest{
				CollectionID:           s.collectionID,
				TaskID:                 s.taskID,
				PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
				StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
				ExternalSpec:           `{"format":"parquet"}`,
				Schema:                 &schemapb.CollectionSchema{},
			}
			task := s.newTask(ctx, req)
			task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
			task.nextAllocID = task.preallocatedIDRange.Begin
			task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

			var calls int
			mockEnsure := mockey.Mock(ensureContext).To(func(ctx context.Context) error {
				calls++
				if calls == tc.cancelAt {
					return context.Canceled
				}
				return nil
			}).Build()
			defer mockEnsure.UnPatch()

			result, err := task.balanceFragmentsToSegments(ctx, tc.fragments)
			s.ErrorIs(err, context.Canceled)
			s.Nil(result)
		})
	}
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_ContextCanceledInWorker() {
	paramtable.Init()

	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSpec:           `{"format":"parquet"}`,
		Schema:                 &schemapb.CollectionSchema{},
	}
	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	mockEnsure := mockey.Mock(ensureContext).Return(nil).Build()
	defer mockEnsure.UnPatch()

	result, err := task.balanceFragmentsToSegments(ctx, []packed.Fragment{{FragmentID: 1, RowCount: 100}})
	s.ErrorIs(err, context.Canceled)
	s.Nil(result)
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_FunctionOutputEstimateError() {
	paramtable.Init()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
				{FieldID: 101, Name: "bad_output", DataType: schemapb.DataType_VarChar, IsFunctionOutput: true},
			},
		},
	}
	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	mockManifest := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		Return("manifest.json", nil).Build()
	defer mockManifest.UnPatch()
	mockSample := mockey.Mock(packed.SampleExternalFieldSizes).
		Return(map[string]int64{"text_col": 64}, nil).Build()
	defer mockSample.UnPatch()

	result, err := task.balanceFragmentsToSegments(ctx, []packed.Fragment{{FragmentID: 1, RowCount: 100}})
	s.Error(err)
	s.Contains(err.Error(), "estimate function output field bad_output")
	s.Nil(result)
}

// TestBalanceFragmentsToSegments_PassesStorageConfigAndSpecExtfs verifies that
// balanceFragmentsToSegments correctly passes storageConfig and specExtfs
// (built from parsedSpec) to SampleExternalFieldSizes. This is the core change
// that eliminates the dependency on C++ LoonFFIPropertiesSingleton.
func (s *RefreshExternalCollectionTaskSuite) TestEstimateFunctionOutputBytesPerRow_NoOutputs() {
	bytes, err := estimateFunctionOutputBytesPerRow(&schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar},
		},
	})

	s.NoError(err)
	s.Equal(int64(0), bytes)
}

func (s *RefreshExternalCollectionTaskSuite) TestEstimateFunctionOutputBytesPerRow_VectorOutputs() {
	bytes, err := estimateFunctionOutputBytesPerRow(&schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{
				FieldID:          101,
				Name:             "float_vec",
				DataType:         schemapb.DataType_FloatVector,
				IsFunctionOutput: true,
				TypeParams:       []*commonpb.KeyValuePair{{Key: "dim", Value: "4"}},
			},
			{
				FieldID:          102,
				Name:             "int8_vec",
				DataType:         schemapb.DataType_Int8Vector,
				IsFunctionOutput: true,
				TypeParams:       []*commonpb.KeyValuePair{{Key: "dim", Value: "8"}},
			},
			{
				FieldID:          103,
				Name:             "binary_vec",
				DataType:         schemapb.DataType_BinaryVector,
				IsFunctionOutput: true,
				TypeParams:       []*commonpb.KeyValuePair{{Key: "dim", Value: "64"}},
			},
		},
	})

	s.NoError(err)
	s.Equal(int64(16+8+8), bytes)
}

func (s *RefreshExternalCollectionTaskSuite) TestEstimateFunctionOutputBytesPerRow_ScalarOutputs() {
	bytes, err := estimateFunctionOutputBytesPerRow(&schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 101, Name: "score", DataType: schemapb.DataType_Int64, IsFunctionOutput: true},
			{
				FieldID:          102,
				Name:             "label",
				DataType:         schemapb.DataType_VarChar,
				IsFunctionOutput: true,
				TypeParams:       []*commonpb.KeyValuePair{{Key: "max_length", Value: "20"}},
			},
		},
	})

	s.NoError(err)
	s.Equal(int64(8+20), bytes)
}

func (s *RefreshExternalCollectionTaskSuite) TestEstimateFunctionOutputBytesPerRow_InvalidOutputField() {
	_, err := estimateFunctionOutputBytesPerRow(&schemapb.CollectionSchema{
		Fields: []*schemapb.FieldSchema{
			{FieldID: 101, Name: "bad_text", DataType: schemapb.DataType_VarChar, IsFunctionOutput: true},
		},
	})

	s.Error(err)
	s.Contains(err.Error(), "estimate function output field bad_text")
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_AddsFunctionOutputMemorySize() {
	paramtable.Init()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "s3://bucket/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", DataType: schemapb.DataType_VarChar, ExternalField: "text_col"},
				{
					FieldID:          101,
					Name:             "embedding",
					DataType:         schemapb.DataType_FloatVector,
					IsFunctionOutput: true,
					TypeParams:       []*commonpb.KeyValuePair{{Key: "dim", Value: "4"}},
				},
				{
					FieldID:          102,
					Name:             "scalar_output",
					DataType:         schemapb.DataType_VarChar,
					IsFunctionOutput: true,
					TypeParams:       []*commonpb.KeyValuePair{{Key: "max_length", Value: "20"}},
				},
			},
		},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	m1 := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(ctx context.Context, basePath, format string, columns []string, fragments []packed.Fragment, storageConfig *indexpb.StorageConfig, extfs packed.ExternalSpecContext) (string, error) {
			return fmt.Sprintf("%s/manifest.json", basePath), nil
		}).Build()
	defer m1.UnPatch()

	m2 := mockey.Mock(packed.SampleExternalFieldSizes).
		Return(map[string]int64{"text_col": 64}, nil).Build()
	defer m2.UnPatch()

	fragments := []packed.Fragment{{FragmentID: 1, RowCount: 10}}

	result, err := task.balanceFragmentsToSegments(context.Background(), fragments)
	s.NoError(err)
	s.Len(result, 1)
	s.Len(result[0].GetBinlogs(), 1)
	s.Len(result[0].GetBinlogs()[0].GetBinlogs(), 1)
	s.Equal(int64((64+16+20)*10), result[0].GetBinlogs()[0].GetBinlogs()[0].GetMemorySize())
}

func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_PassesStorageConfigAndSpecExtfs() {
	paramtable.Init()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	expectedStorageConfig := &indexpb.StorageConfig{
		StorageType:     "minio",
		Address:         "localhost:9000",
		BucketName:      "test-bucket",
		AccessKeyID:     "minioadmin",
		SecretAccessKey: "minioadmin",
	}

	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          expectedStorageConfig,
		ExternalSource:         "s3://s3.us-west-2.amazonaws.com/ext-bucket/data/",
		ExternalSpec:           `{"format":"parquet","extfs":{"region":"us-west-2","use_ssl":"true","cloud_provider":"aws"}}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "vec", ExternalField: "vec_col"},
			},
		},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	// Parse the spec so the balance path has an in-memory struct to work with.
	parsed, err := externalspec.ParseExternalSpec(req.GetExternalSpec())
	s.Require().NoError(err)
	task.parsedSpec = parsed

	m1 := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(ctx context.Context, basePath, format string, columns []string, fragments []packed.Fragment, sc *indexpb.StorageConfig, extfs packed.ExternalSpecContext) (string, error) {
			return fmt.Sprintf("%s/manifest.json", basePath), nil
		}).Build()
	defer m1.UnPatch()

	var capturedStorageConfig *indexpb.StorageConfig
	var capturedExternalSpec string
	var capturedSchema *schemapb.CollectionSchema
	m2 := mockey.Mock(packed.SampleExternalFieldSizes).
		To(func(manifestPath string, rows int, collectionID int64, externalSource, externalSpec string, schema *schemapb.CollectionSchema, storageConfig *indexpb.StorageConfig) (map[string]int64, error) {
			capturedSchema = schema
			capturedStorageConfig = storageConfig
			capturedExternalSpec = externalSpec
			return map[string]int64{"vec_col": 3072}, nil
		}).Build()
	defer m2.UnPatch()

	fragments := []packed.Fragment{
		{FragmentID: 1, RowCount: 500},
	}

	result, err := task.balanceFragmentsToSegments(context.Background(), fragments)
	s.NoError(err)
	s.Len(result, 1)

	// Verify storageConfig was passed through (not nil)
	s.NotNil(capturedStorageConfig, "storageConfig must be passed to SampleExternalFieldSizes")
	s.Equal(expectedStorageConfig.GetAddress(), capturedStorageConfig.GetAddress())
	s.Equal(expectedStorageConfig.GetBucketName(), capturedStorageConfig.GetBucketName())
	s.Equal(expectedStorageConfig.GetStorageType(), capturedStorageConfig.GetStorageType())
	s.Equal(req.GetSchema(), capturedSchema, "schema must be passed to SampleExternalFieldSizes")

	// Verify raw externalSpec JSON is forwarded so C++ InjectExternalSpecProperties
	// can derive extfs.* overrides.
	s.Equal(req.GetExternalSpec(), capturedExternalSpec)
}

// TestBalanceFragmentsToSegments_NilSpecExtfsWhenNoExtfsInSpec verifies that
// when ExternalSpec has no extfs overrides (same-bucket scenario), specExtfs
// is empty but storageConfig is still passed.
func (s *RefreshExternalCollectionTaskSuite) TestBalanceFragmentsToSegments_NilSpecExtfsWhenNoExtfsInSpec() {
	paramtable.Init()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	req := &datapb.RefreshExternalCollectionTaskRequest{
		CollectionID:           s.collectionID,
		TaskID:                 s.taskID,
		PreAllocatedSegmentIds: &datapb.IDRange{Begin: 100, End: 200},
		StorageConfig:          &indexpb.StorageConfig{StorageType: "local"},
		ExternalSource:         "/local/data/",
		ExternalSpec:           `{"format":"parquet"}`,
		Schema: &schemapb.CollectionSchema{
			Fields: []*schemapb.FieldSchema{
				{FieldID: 100, Name: "text", ExternalField: "text_col"},
			},
		},
	}

	task := s.newTask(ctx, req)
	task.preallocatedIDRange = req.GetPreAllocatedSegmentIds()
	task.nextAllocID = task.preallocatedIDRange.Begin
	task.parsedSpec = &externalspec.ExternalSpec{Format: "parquet"}

	m1 := mockey.Mock(packed.CreateSegmentManifestWithBasePathAndExtfs).
		To(func(ctx context.Context, basePath, format string, columns []string, fragments []packed.Fragment, sc *indexpb.StorageConfig, extfs packed.ExternalSpecContext) (string, error) {
			return fmt.Sprintf("%s/manifest.json", basePath), nil
		}).Build()
	defer m1.UnPatch()

	var capturedStorageConfig *indexpb.StorageConfig
	var capturedExternalSpec string
	m2 := mockey.Mock(packed.SampleExternalFieldSizes).
		To(func(manifestPath string, rows int, collectionID int64, externalSource, externalSpec string, schema *schemapb.CollectionSchema, storageConfig *indexpb.StorageConfig) (map[string]int64, error) {
			capturedStorageConfig = storageConfig
			capturedExternalSpec = externalSpec
			return map[string]int64{"text_col": 64}, nil
		}).Build()
	defer m2.UnPatch()

	fragments := []packed.Fragment{
		{FragmentID: 1, RowCount: 100},
	}

	result, err := task.balanceFragmentsToSegments(context.Background(), fragments)
	s.NoError(err)
	s.Len(result, 1)

	// storageConfig is always passed
	s.NotNil(capturedStorageConfig)
	s.Equal("local", capturedStorageConfig.GetStorageType())

	// Same-bucket: externalSpec has no extfs → C++ inject is a no-op.
	s.Equal(req.GetExternalSpec(), capturedExternalSpec)
}

func TestRefreshExternalCollectionTaskSuite(t *testing.T) {
	suite.Run(t, new(RefreshExternalCollectionTaskSuite))
}
