package datacoord

import (
	"context"
	"math"

	"github.com/samber/lo"

	"github.com/milvus-io/milvus-proto/go-api/v3/milvuspb"
	"github.com/milvus-io/milvus/internal/datacoord/allocator"
	"github.com/milvus-io/milvus/internal/datacoord/session"
	"github.com/milvus-io/milvus/internal/types"
	"github.com/milvus-io/milvus/internal/util/sessionutil"
	"github.com/milvus-io/milvus/pkg/v3/common"
	"github.com/milvus-io/milvus/pkg/v3/mlog"
	"github.com/milvus-io/milvus/pkg/v3/util/merr"
	"github.com/milvus-io/milvus/pkg/v3/util/metricsinfo"
	"github.com/milvus-io/milvus/pkg/v3/util/paramtable"
	"github.com/milvus-io/milvus/pkg/v3/util/typeutil"
)

const (
	// Fallback memory for pooling DataNode (returns 0 from GetMetrics)
	defaultPoolingDataNodeMemory = 32 * 1024 * 1024 * 1024 // 32GB
)

// CollectionTopology captures memory constraints for a collection
type CollectionTopology struct {
	CollectionID     int64
	NumReplicas      int
	NumShards        int
	IsStandaloneMode bool
	IsPooling        bool

	QueryNodeMemory map[int64]uint64
	DataNodeMemory  map[int64]uint64
}

// CollectionTopologyQuerier queries collection topology including replicas and memory info
type CollectionTopologyQuerier interface {
	GetCollectionTopology(ctx context.Context, collectionID int64) (*CollectionTopology, error)
}

type forceMergeCompactionPolicy struct {
	meta            *meta
	allocator       allocator.Allocator
	handler         Handler
	topologyQuerier CollectionTopologyQuerier
}

func newForceMergeCompactionPolicy(meta *meta, allocator allocator.Allocator, handler Handler) *forceMergeCompactionPolicy {
	return &forceMergeCompactionPolicy{
		meta:            meta,
		allocator:       allocator,
		handler:         handler,
		topologyQuerier: nil,
	}
}

func (policy *forceMergeCompactionPolicy) SetTopologyQuerier(querier CollectionTopologyQuerier) {
	policy.topologyQuerier = querier
}

func (policy *forceMergeCompactionPolicy) triggerOneCollection(
	ctx context.Context,
	collectionID int64,
	targetSize int64,
) ([]CompactionView, int64, error) {
	log := mlog.With(
		mlog.FieldCollectionID(collectionID),
		mlog.Int64("targetSize", targetSize))
	if policy.meta.isCollectionCompactionBlocked(collectionID) {
		log.Info(ctx, "skip force merge compaction for collection due to unloaded protected snapshot RefIndex",
			mlog.FieldCollectionID(collectionID))
		return nil, 0, nil
	}
	collection, err := policy.handler.GetCollection(ctx, collectionID)
	if err != nil {
		return nil, 0, err
	}
	triggerID, err := policy.allocator.AllocID(ctx)
	if err != nil {
		return nil, 0, err
	}

	collectionTTL, err := common.GetCollectionTTLFromMap(collection.Properties)
	if err != nil {
		log.Warn(ctx, "failed to get collection ttl, use default", mlog.Err(err))
		collectionTTL = 0
	}

	// Convert targetSize from MB to bytes (per design doc: targetSize is in MB)
	// Handle overflow: when targetSize is very large (e.g., max_int64 for auto-calculate mode)
	var targetSizeBytes int64
	if targetSize > math.MaxInt64/(1024*1024) {
		targetSizeBytes = math.MaxInt64
	} else {
		targetSizeBytes = targetSize * 1024 * 1024
	}

	configMaxSize := getExpectedSegmentSize(policy.meta, collectionID, collection.Schema)
	if targetSizeBytes < configMaxSize {
		return nil, 0, merr.WrapErrParameterInvalidMsg("targetSize %d MB should be greater than or equal to configMaxSize %d MB", targetSize, configMaxSize/(1024*1024))
	}

	segments := policy.meta.SelectSegments(ctx, WithCollection(collectionID), SegmentFilterFunc(func(segment *SegmentInfo) bool {
		return isNormalManualCompactionCandidate(policy.meta, segment)
	}))

	if len(segments) == 0 {
		log.Info(ctx, "no eligible segments for force merge")
		return nil, 0, nil
	}

	topology, err := policy.topologyQuerier.GetCollectionTopology(ctx, collectionID)
	if err != nil {
		return nil, 0, err
	}
	topology.NumShards = len(collection.VChannelNames)

	views := []CompactionView{}
	for label, groups := range groupByPartitionChannel(GetViewsByInfo(segments...)) {
		view := &ForceMergeSegmentView{
			label:         label,
			segments:      groups,
			triggerID:     triggerID,
			collectionTTL: collectionTTL,

			configMaxSize:      float64(configMaxSize),
			expectedTargetSize: float64(targetSizeBytes),
			topology:           topology,
		}
		views = append(views, view)
	}

	log.Info(ctx, "force merge triggered", mlog.Int("viewCount", len(views)))
	return views, triggerID, nil
}

func groupByPartitionChannel(segments []*SegmentView) map[*CompactionGroupLabel][]*SegmentView {
	result := make(map[*CompactionGroupLabel][]*SegmentView)

	for _, seg := range segments {
		label := seg.label
		key := label.Key()

		var foundLabel *CompactionGroupLabel
		for l := range result {
			if l.Key() == key {
				foundLabel = l
				break
			}
		}
		if foundLabel == nil {
			foundLabel = label
		}

		result[foundLabel] = append(result[foundLabel], seg)
	}

	return result
}

type metricsNodeMemoryQuerier struct {
	nodeManager session.NodeManager
	mixCoord    types.MixCoord
	session     sessionutil.SessionInterface
}

func newMetricsNodeMemoryQuerier(nodeManager session.NodeManager, mixCoord types.MixCoord, session sessionutil.SessionInterface) *metricsNodeMemoryQuerier {
	return &metricsNodeMemoryQuerier{
		nodeManager: nodeManager,
		mixCoord:    mixCoord,
		session:     session,
	}
}

var _ CollectionTopologyQuerier = (*metricsNodeMemoryQuerier)(nil)

func (q *metricsNodeMemoryQuerier) GetCollectionTopology(ctx context.Context, collectionID int64) (*CollectionTopology, error) {
	log := mlog.With(mlog.FieldCollectionID(collectionID))
	if q.mixCoord == nil {
		return nil, merr.WrapErrServiceInternalMsg("mixCoord not available for topology query")
	}

	// 1. Get replica information
	replicasResp, err := q.mixCoord.GetReplicas(ctx, &milvuspb.GetReplicasRequest{
		CollectionID: collectionID,
	})
	if err != nil {
		return nil, err
	}
	numReplicas := len(replicasResp.GetReplicas())

	// 2. Get QueryNode metrics for memory info
	req, err := metricsinfo.ConstructRequestByMetricType(metricsinfo.SystemInfoMetrics)
	if err != nil {
		return nil, err
	}

	// Get QueryNode sessions from etcd to filter out embedded nodes
	sessions, _, err := q.session.GetSessions(ctx, typeutil.QueryNodeRole)
	if err != nil {
		log.Warn(ctx, "failed to get QueryNode sessions", mlog.Err(err))
		return nil, err
	}

	// Build set of embedded QueryNode IDs to exclude
	embeddedNodeIDs := make(map[int64]struct{})
	for _, sess := range sessions {
		// Check if this is an embedded QueryNode in streaming node
		if labels := sess.ServerLabels; labels != nil {
			if labels[sessionutil.LabelStreamingNodeEmbeddedQueryNode] == "1" {
				embeddedNodeIDs[sess.ServerID] = struct{}{}
			}
		}
	}

	log.Info(ctx, "excluding embedded QueryNode", mlog.Int64s("nodeIDs", lo.Keys(embeddedNodeIDs)))
	rsp, err := q.mixCoord.GetQcMetrics(ctx, req)
	if err = merr.CheckRPCCall(rsp, err); err != nil {
		return nil, err
	}
	topology := &metricsinfo.QueryCoordTopology{}
	if err := metricsinfo.UnmarshalTopology(rsp.GetResponse(), topology); err != nil {
		return nil, err
	}

	// Build QueryNode memory map: nodeID → memory size (exclude embedded nodes)
	queryNodeMemory := make(map[int64]uint64)
	for _, node := range topology.Cluster.ConnectedNodes {
		if _, ok := embeddedNodeIDs[node.ID]; ok {
			continue
		}
		queryNodeMemory[node.ID] = node.HardwareInfos.Memory
	}

	// 3. Get DataNode memory info
	dataNodeMemory := make(map[int64]uint64)
	isPooling := false
	nodes := q.nodeManager.GetClientIDs()
	for _, nodeID := range nodes {
		cli, err := q.nodeManager.GetClient(nodeID)
		if err != nil {
			continue
		}

		resp, err := cli.GetMetrics(ctx, req)
		if err != nil {
			continue
		}

		var infos metricsinfo.DataNodeInfos
		if err := metricsinfo.UnmarshalComponentInfos(resp.GetResponse(), &infos); err != nil {
			continue
		}

		if infos.HardwareInfos.Memory > 0 {
			dataNodeMemory[nodeID] = infos.HardwareInfos.Memory
		} else {
			// Pooling DataNode returns 0 from GetMetrics
			// Use default fallback: 32GB
			isPooling = true
			log.Warn(ctx, "DataNode returned 0 memory (pooling mode?), using default",
				mlog.FieldNodeID(nodeID),
				mlog.Uint64("defaultMemory", defaultPoolingDataNodeMemory))
			dataNodeMemory[nodeID] = defaultPoolingDataNodeMemory
		}
	}

	isStandaloneMode := paramtable.GetRole() == typeutil.StandaloneRole
	log.Info(ctx, "Collection topology",
		mlog.FieldCollectionID(collectionID),
		mlog.Int("numReplicas", numReplicas),
		mlog.Any("querynodes", queryNodeMemory),
		mlog.Any("datanodes", dataNodeMemory),
		mlog.Bool("isStandaloneMode", isStandaloneMode),
		mlog.Bool("isPooling", isPooling))

	return &CollectionTopology{
		CollectionID:     collectionID,
		NumReplicas:      numReplicas,
		QueryNodeMemory:  queryNodeMemory,
		DataNodeMemory:   dataNodeMemory,
		IsStandaloneMode: isStandaloneMode,
		IsPooling:        isPooling,
	}, nil
}
