// Package ecbalancer holds the EC-shard rebalancing policy shared by the shell
// ec.balance command and the admin EC balance worker. It is pure: callers build
// a Topology snapshot from their own structures, call Plan to get the list of
// shard Moves, and execute them their own way (inline RPCs in the shell, task
// proposals in the worker). Keeping the policy here stops the two callers from
// drifting apart.
package ecbalancer

import (
	"math"
	"sort"

	"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
	"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
)

// Topology is a snapshot of EC shard placement to plan against. Build it with
// NewTopology + AddNode + (*Node).AddDisk/AddShards.
type Topology struct {
	nodes map[string]*Node
}

// volKey identifies an EC volume by (collection, id). A numeric volume id can be
// reused across collections, so the collection is part of the identity (see
// weed/storage/store_ec_attach_reservation.go); keying shards by id alone would
// merge unrelated volumes and could dedup/move shards across collections.
type volKey struct {
	collection string
	vid        uint32
}

// Node is a volume server in the snapshot. Fields are set through the builder
// methods; only its identity is read back (via Move).
type Node struct {
	id   string
	host string // physical machine (host/IP); nodes sharing a host are one fault domain
	dc   string
	rack string // composite rack key (e.g. "dc1:rack1")

	freeSlots int
	disks     map[uint32]*disk
	shards    map[volKey]*volumeShards
}

type disk struct {
	diskID     uint32
	diskType   string
	tags       []string // placement tags, for preferred-tag tiering
	freeSlots  int
	shardCount int // total EC shards on this disk across all volumes
}

type volumeShards struct {
	collection    string
	shardBits     erasure_coding.ShardBits            // union across disks
	diskShardBits map[uint32]erasure_coding.ShardBits // disk_id -> shards of this volume on that disk
}

type rack struct {
	nodes     map[string]*Node
	freeSlots int
}

// Move is a planned shard relocation. For a dedup deletion SourceNode==TargetNode
// and SourceDisk==TargetDisk (unmount+delete only, no copy).
type Move struct {
	VolumeID   uint32
	ShardID    int
	Collection string
	SourceNode string
	SourceDisk uint32
	SourceRack string
	TargetNode string
	TargetDisk uint32
	TargetRack string
	Phase      string // "dedup", "cross_rack", "within_rack", "global"
	// KeepNode is set for dedup moves: the node the plan chose to keep the
	// shard on. The executor confirms it really holds the shard before
	// deleting the copy.
	KeepNode string
}

// Options tunes a Plan run.
type Options struct {
	DiskType           string                        // "" matches any disk type
	ImbalanceThreshold float64                       // skip rack/node balancing below this skew
	ReplicaPlacement   *super_block.ReplicaPlacement // nil = even spread, no per-rack/node cap
	// Ratio returns a collection's (dataShards, parityShards); nil defaults to the
	// standard scheme. This is where a caller plugs in custom-ratio resolution.
	Ratio func(collection string) (dataShards, parityShards int)
	// VolumeRatio returns a single volume's (dataShards, parityShards) when its
	// heartbeat reported a per-volume ratio; either return <=0 to defer to Ratio
	// (the collection ratio). A single collection can hold volumes of mixed ratios
	// (a ratio change after some volumes were encoded), so placement must classify
	// and spread each volume by its OWN data/parity split, not the collection's.
	// nil (and the 0/OSS case) makes the planner fall back to Ratio per collection,
	// preserving the collection-keyed behavior.
	VolumeRatio func(collection string, vid uint32) (dataShards, parityShards int)
	// GlobalMaxMovesPerRack caps how many shards the global (cross-volume) phase
	// moves out of one rack in a single Plan. 0 means unlimited (drain to balance
	// in one pass), which the shell uses; the worker sets a small value to make
	// incremental progress across repeated detection cycles.
	GlobalMaxMovesPerRack int
	// GlobalUtilizationBased selects the global phase's balance metric: when true,
	// nodes are balanced by fractional fullness (shards/capacity), which suits
	// heterogeneous-capacity racks; when false, by raw shard count. Both the worker
	// and the shell enable it; the two metrics agree when capacities are uniform.
	GlobalUtilizationBased bool
}

// move is the internal form carrying node pointers; converted to Move at the end.
type move struct {
	volumeID   uint32
	shardID    int
	collection string
	source     *Node
	sourceDisk uint32
	target     *Node
	targetDisk uint32
	phase      string
	keepNode   string
}

// NewTopology returns an empty topology to populate.
func NewTopology() *Topology {
	return &Topology{nodes: make(map[string]*Node)}
}

// AddNode registers a volume server. freeSlots is the node's total free EC shard
// slots; per-disk free slots are supplied via AddDisk.
func (t *Topology) AddNode(id, dc, rackKey string, freeSlots int) *Node {
	n := &Node{
		id:        id,
		host:      id, // default: each node is its own machine until SetHost overrides
		dc:        dc,
		rack:      rackKey,
		freeSlots: freeSlots,
		disks:     make(map[uint32]*disk),
		shards:    make(map[volKey]*volumeShards),
	}
	t.nodes[id] = n
	return n
}

// SetHost sets the physical machine (host/IP) a node runs on; nodes sharing a host
// are one fault domain. Defaults to the node id (one machine per node) if unset.
func (n *Node) SetHost(host string) {
	if host != "" {
		n.host = host
	}
}

// AddDisk registers a physical disk. shardCount is the disk's total EC shard
// count across all volumes (used for disk scoring); freeSlots is the per-disk
// free EC shard slots.
func (n *Node) AddDisk(diskID uint32, diskType string, freeSlots, shardCount int) {
	n.disks[diskID] = &disk{diskID: diskID, diskType: diskType, freeSlots: freeSlots, shardCount: shardCount}
}

// AddDiskTags records placement tags (e.g. "ssd","fast") for a disk, used by
// preferred-tag tiering in Place. Call after AddDisk; a no-op if the disk is unknown.
func (n *Node) AddDiskTags(diskID uint32, tags []string) {
	if d, ok := n.disks[diskID]; ok {
		d.tags = append([]string(nil), tags...)
	}
}

// AddShards records that the volume's shards in bits live on diskID. Call it
// only for the volumes that should be balanced; the disk's overall occupancy is
// reported separately via AddDisk.
func (n *Node) AddShards(vid uint32, collection string, diskID uint32, bits erasure_coding.ShardBits) {
	key := volKey{collection: collection, vid: vid}
	vs, ok := n.shards[key]
	if !ok {
		vs = &volumeShards{collection: collection, diskShardBits: make(map[uint32]erasure_coding.ShardBits)}
		n.shards[key] = vs
	}
	vs.shardBits |= bits
	vs.diskShardBits[diskID] |= bits
}

// Plan runs the full multi-phase EC balance policy and returns the proposed
// moves: per collection it deduplicates, then spreads data and parity shards
// across racks and within racks (two-pass, with anti-affinity), and finally
// balances total shard load across nodes in each rack.
func Plan(topo *Topology, opts Options) []Move {
	if topo == nil || len(topo.nodes) == 0 {
		return nil
	}
	ratio := opts.Ratio
	if ratio == nil {
		ratio = func(string) (int, int) {
			return erasure_coding.DataShardsCount, erasure_coding.ParityShardsCount
		}
	}

	nodes := topo.nodes
	racks := buildRacks(nodes)

	// Group volumes by collection (deterministic order), keyed by (collection, id)
	// so volumes that reuse a numeric id across collections stay distinct.
	byCollection := make(map[string][]volKey)
	seen := make(map[volKey]bool)
	for _, n := range nodes {
		for vk := range n.shards {
			if !seen[vk] {
				seen[vk] = true
				byCollection[vk.collection] = append(byCollection[vk.collection], vk)
			}
		}
	}
	collections := make([]string, 0, len(byCollection))
	for c := range byCollection {
		collections = append(collections, c)
		sort.Slice(byCollection[c], func(i, j int) bool { return byCollection[c][i].vid < byCollection[c][j].vid })
	}
	sort.Strings(collections)

	// Resolve each volume's data/parity split: prefer the per-volume ratio the
	// heartbeat reported (Options.VolumeRatio), fall back to the collection ratio,
	// then the build defaults via `ratio`. Keyed by volume so a mixed-ratio
	// collection (e.g. a 9+3 volume beside a 10+4 one) is classified and spread per
	// volume rather than against one collection-wide split.
	dataShardsByVolume := make(map[volKey]int)
	parityShardsByVolume := make(map[volKey]int)
	for _, collection := range collections {
		defaultD, defaultP := ratio(collection)
		for _, vk := range byCollection[collection] {
			d, p := defaultD, defaultP
			if opts.VolumeRatio != nil {
				vd, vp := opts.VolumeRatio(vk.collection, vk.vid)
				if vd > 0 {
					d = vd
				}
				if vp > 0 {
					p = vp
				}
			}
			dataShardsByVolume[vk] = d
			parityShardsByVolume[vk] = p
		}
	}

	var all []*move
	for _, collection := range collections {
		for _, vk := range byCollection[collection] {
			m := detectDuplicateShards(vk, nodes)
			applyMovesToTopology(m, racks)
			all = append(all, m...)
		}
		for _, vk := range byCollection[collection] {
			m := detectCrossRackImbalance(vk, nodes, racks, opts.DiskType, opts.ImbalanceThreshold, dataShardsByVolume[vk], parityShardsByVolume[vk], opts.ReplicaPlacement)
			applyMovesToTopology(m, racks)
			all = append(all, m...)
		}
		for _, vk := range byCollection[collection] {
			m := detectWithinRackImbalance(vk, nodes, racks, opts.DiskType, opts.ImbalanceThreshold, dataShardsByVolume[vk], parityShardsByVolume[vk], opts.ReplicaPlacement)
			applyMovesToTopology(m, racks)
			all = append(all, m...)
		}
	}

	all = append(all, detectGlobalImbalance(nodes, racks, opts.DiskType, opts.ImbalanceThreshold, dataShardsByVolume, parityShardsByVolume, opts.GlobalMaxMovesPerRack, opts.GlobalUtilizationBased)...)

	out := make([]Move, 0, len(all))
	for _, m := range all {
		out = append(out, Move{
			VolumeID:   m.volumeID,
			ShardID:    m.shardID,
			Collection: m.collection,
			SourceNode: m.source.id,
			SourceDisk: m.sourceDisk,
			SourceRack: m.source.rack,
			TargetNode: m.target.id,
			TargetDisk: m.targetDisk,
			TargetRack: m.target.rack,
			Phase:      m.phase,
			KeepNode:   m.keepNode,
		})
	}
	return out
}

func buildRacks(nodes map[string]*Node) map[string]*rack {
	racks := make(map[string]*rack)
	for _, n := range nodes {
		r, ok := racks[n.rack]
		if !ok {
			r = &rack{nodes: make(map[string]*Node)}
			racks[n.rack] = r
		}
		r.nodes[n.id] = n
		r.freeSlots += n.freeSlots
	}
	return racks
}

// detectDuplicateShards finds shards present on more than one node and proposes
// deleting all copies but the one on the node with the most free slots.
func detectDuplicateShards(vk volKey, nodes map[string]*Node) []*move {
	shardLocations := make(map[int][]*Node)
	for _, node := range nodes {
		info, ok := node.shards[vk]
		if !ok {
			continue
		}
		for sid := range info.shardBits.All() {
			shardLocations[int(sid)] = append(shardLocations[int(sid)], node)
		}
	}

	var moves []*move
	for shardID, locs := range shardLocations {
		if len(locs) <= 1 {
			continue
		}
		// Keep the copy on the node with the most free slots and delete the
		// duplicates from the more-constrained nodes, relieving capacity pressure
		// where it is tightest. Sort ascending by free slots (tie-break on node id
		// for determinism) and keep the last entry.
		sort.Slice(locs, func(i, j int) bool {
			if locs[i].freeSlots != locs[j].freeSlots {
				return locs[i].freeSlots < locs[j].freeSlots
			}
			return locs[i].id < locs[j].id
		})
		keep := locs[len(locs)-1]
		for _, node := range locs[:len(locs)-1] {
			moves = append(moves, &move{
				volumeID:   vk.vid,
				shardID:    shardID,
				collection: vk.collection,
				source:     node,
				sourceDisk: shardDiskID(node, vk, shardID),
				target:     node,
				targetDisk: shardDiskID(node, vk, shardID),
				phase:      "dedup",
				keepNode:   keep.id,
			})
		}
	}
	return moves
}

// detectCrossRackImbalance spreads a volume's shards across racks in two passes
// (data, then parity with anti-affinity to data-bearing racks). Returns nil if
// the overall distribution is below the imbalance threshold.
func detectCrossRackImbalance(vk volKey, nodes map[string]*Node, racks map[string]*rack, diskType string, threshold float64, dataShards, parityShards int, rp *super_block.ReplicaPlacement) []*move {
	numRacks := len(racks)
	if numRacks <= 1 {
		return nil
	}

	// Gate on per-type spread: act when data OR parity shards are unevenly
	// distributed across racks, even if the per-rack totals happen to be even.
	gateData, gateParity := shardsByGroup(vk, nodes, dataShards, func(n *Node) string { return n.rack })
	if !typeImbalanced(gateData, numRacks, threshold) && !typeImbalanced(gateParity, numRacks, threshold) {
		return nil
	}

	rackShardCount := countShardsByRack(vk, nodes)
	var moves []*move

	dataPerRack, _ := shardsByGroup(vk, nodes, dataShards, func(n *Node) string { return n.rack })
	moves = append(moves, balanceShardTypeAcrossRacks(vk, nodes, racks, diskType, dataShards,
		dataPerRack, rackShardCount, ceilDivide(dataShards, numRacks), nil, rp)...)

	dataPerRack, parityPerRack := shardsByGroup(vk, nodes, dataShards, func(n *Node) string { return n.rack })
	antiAffinity := make(map[string]bool)
	for rackID, shards := range dataPerRack {
		if len(shards) > 0 {
			antiAffinity[rackID] = true
		}
	}
	moves = append(moves, balanceShardTypeAcrossRacks(vk, nodes, racks, diskType, dataShards,
		parityPerRack, rackShardCount, ceilDivide(parityShards, numRacks), antiAffinity, rp)...)

	return moves
}

func balanceShardTypeAcrossRacks(vk volKey, nodes map[string]*Node, racks map[string]*rack, diskType string, dataShards int, shardsPerRack map[string][]int, rackShardCount map[string]int, maxPerRack int, antiAffinity map[string]bool, rp *super_block.ReplicaPlacement) []*move {
	if maxPerRack < 1 {
		maxPerRack = 1
	}
	rackKeys := sortedKeys(racks)

	type pending struct {
		shardID int
		src     *Node
	}
	var toMove []pending
	for _, rackID := range rackKeys {
		shards := append([]int(nil), shardsPerRack[rackID]...)
		if len(shards) <= maxPerRack {
			continue
		}
		sort.Ints(shards)
		for i := 0; i < len(shards)-maxPerRack; i++ {
			if src := nodeInRackHoldingShard(nodes, rackID, vk, shards[i]); src != nil {
				toMove = append(toMove, pending{shards[i], src})
			}
		}
	}

	var moves []*move
	for _, pm := range toMove {
		destRack, ok := pickTarget(rackKeys, shardsPerRack, maxPerRack, antiAffinity,
			func(r string) bool { return racks[r].freeSlots > 0 },
			func(r string) bool {
				if rp == nil {
					return true
				}
				if rp.DiffRackCount > 0 && rackShardCount[r] >= rp.DiffRackCount {
					return false
				}
				return true
			})
		if !ok {
			continue
		}
		destNode := pickNodeInRack(racks[destRack], vk, rp)
		if destNode == nil {
			continue
		}
		destDisk := pickBestDiskOnNode(destNode, vk, diskType, pm.shardID, dataShards)
		moves = append(moves, &move{
			volumeID:   vk.vid,
			shardID:    pm.shardID,
			collection: vk.collection,
			source:     pm.src,
			sourceDisk: shardDiskID(pm.src, vk, pm.shardID),
			target:     destNode,
			targetDisk: destDisk,
			phase:      "cross_rack",
		})
		releaseShard(pm.src, vk, pm.shardID)
		reserveShard(destNode, vk, pm.shardID, destDisk)
		srcRack := pm.src.rack
		shardsPerRack[destRack] = append(shardsPerRack[destRack], pm.shardID)
		shardsPerRack[srcRack] = removeInt(shardsPerRack[srcRack], pm.shardID)
		rackShardCount[destRack]++
		rackShardCount[srcRack]--
		racks[destRack].freeSlots--
		racks[srcRack].freeSlots++
		// Account at the node level too, so pickNodeInRack does not over-plan a
		// limited-capacity destination across successive moves.
		destNode.freeSlots--
		pm.src.freeSlots++
	}
	return moves
}

func pickNodeInRack(r *rack, vk volKey, rp *super_block.ReplicaPlacement) *Node {
	return pickBestNodeForVolume(sortedNodeSlice(r.nodes), vk, rp)
}

// pickBestNodeForVolume returns the node with the fewest shards of the volume that
// has a free slot and is under the SameRackCount cap, or nil.
func pickBestNodeForVolume(nodes []*Node, vk volKey, rp *super_block.ReplicaPlacement) *Node {
	var best *Node
	bestCount := -1
	for _, node := range nodes {
		if node.freeSlots <= 0 {
			continue
		}
		count := volumeShardCount(node, vk)
		if rp != nil && rp.SameRackCount > 0 && count >= rp.SameRackCount {
			continue
		}
		if best == nil || count < bestCount {
			best, bestCount = node, count
		}
	}
	return best
}

// detectWithinRackImbalance spreads a volume's shards within each rack, data then
// parity with anti-affinity. It spreads across machines (the fault domain) only when
// the rack has enough that each can stay within EC's parity tolerance; otherwise
// machine spreading buys no durability and would only fight capacity (e.g. cramming
// a 2-server box while a 12-server box sits idle), so it spreads across nodes and
// lets capacity/global balancing decide.
// The imbalance threshold gates only the node fallback (cosmetic load distribution
// that should defer to the global utilization phase). Machine spreading bypasses it:
// the even cap is a durability bound, and a relative-skew gate would skip e.g. a
// 5/4/3 machine layout for a 10+4 volume ((5-3)/4 = 0.5), leaving 5 shards -- past
// parity -- on one machine.
func detectWithinRackImbalance(vk volKey, nodes map[string]*Node, racks map[string]*rack, diskType string, threshold float64, dataShards, parityShards int, rp *super_block.ReplicaPlacement) []*move {
	var moves []*move

	for _, rackID := range sortedKeys(racks) {
		r := racks[rackID]
		machines := buildMachines(r)
		numMachines, numNodes := len(machines), len(r.nodes)

		// Feasibility is about this rack's share of the volume (cross-rack spreading
		// already moved the rest elsewhere), not the whole volume: a rack holding 7 of
		// a 10+4 volume's shards can keep each of 2 machines within parity even though
		// all 14 could not.
		rackShards := rackVolumeShardCount(r, vk)
		if numMachines > 1 && numMachines < numNodes && parityShards > 0 && ceilDivide(rackShards, numMachines) <= parityShards {
			moves = append(moves, withinRackMachineSpread(vk, r, machines, diskType, dataShards, rp)...)
		} else if numNodes > 1 {
			moves = append(moves, withinRackNodeSpread(vk, r, diskType, threshold, dataShards, rp)...)
		}
	}
	return moves
}

// withinRackMachineSpread spreads a volume's shards across a rack's machines so no
// machine holds more than ceil(rackShards/numMachines). EC recovers from any loss
// within parity regardless of shard type, so what matters per machine is the
// combined count, not data and parity separately: spreading the two independently
// can stack their remainders onto one machine (ceil(d/M)+ceil(p/M) > ceil(total/M))
// and push it past parity. Data/parity anti-affinity is kept at the disk level by
// pickBestDiskOnNode. With one node per machine this reduces to the node spread.
func withinRackMachineSpread(vk volKey, r *rack, machines map[string][]*Node, diskType string, dataShards int, rp *super_block.ReplicaPlacement) []*move {
	machineKeys := sortedKeys(machines)
	shardsPerMachine := make(map[string][]int, len(machines))
	total := 0
	for _, host := range machineKeys {
		for _, n := range machines[host] {
			if info, ok := n.shards[vk]; ok {
				for sid := range info.shardBits.All() {
					shardsPerMachine[host] = append(shardsPerMachine[host], int(sid))
				}
			}
		}
		sort.Ints(shardsPerMachine[host])
		total += len(shardsPerMachine[host])
	}
	if total == 0 {
		return nil
	}
	// Cap = even share. The move loop below sheds only what exceeds it, so a balanced
	// rack is a no-op while any machine over the cap (a parity risk) is always fixed.
	maxPerMachine := ceilDivide(total, len(machines))
	if maxPerMachine < 1 {
		maxPerMachine = 1
	}

	type pending struct {
		shardID int
		src     *Node
	}
	var toMove []pending
	for _, host := range machineKeys {
		shards := shardsPerMachine[host]
		for i := 0; i < len(shards)-maxPerMachine; i++ {
			if src := nodeHoldingShard(machines[host], vk, shards[i]); src != nil {
				toMove = append(toMove, pending{shards[i], src})
			}
		}
	}

	var moves []*move
	for _, pm := range toMove {
		// A machine is a viable target only if a node on it can actually take the
		// shard (free slot, under SameRackCount), so a capped machine is skipped
		// rather than settled on and the move dropped.
		destHost, ok := pickTarget(machineKeys, shardsPerMachine, maxPerMachine, nil,
			func(h string) bool { return h != pm.src.host && pickBestNodeForVolume(machines[h], vk, rp) != nil },
			func(string) bool { return true })
		if !ok {
			continue
		}
		destNode := pickBestNodeForVolume(machines[destHost], vk, rp)
		if destNode == nil {
			continue
		}
		destDisk := pickBestDiskOnNode(destNode, vk, diskType, pm.shardID, dataShards)
		moves = append(moves, &move{
			volumeID:   vk.vid,
			shardID:    pm.shardID,
			collection: vk.collection,
			source:     pm.src,
			sourceDisk: shardDiskID(pm.src, vk, pm.shardID),
			target:     destNode,
			targetDisk: destDisk,
			phase:      "within_rack",
		})
		releaseShard(pm.src, vk, pm.shardID)
		reserveShard(destNode, vk, pm.shardID, destDisk)
		shardsPerMachine[destHost] = append(shardsPerMachine[destHost], pm.shardID)
		shardsPerMachine[pm.src.host] = removeInt(shardsPerMachine[pm.src.host], pm.shardID)
		pm.src.freeSlots++
		destNode.freeSlots--
	}
	return moves
}

// withinRackNodeSpread spreads a volume's shards evenly across a rack's nodes (data
// then parity, parity anti-affine to data-bearing nodes). This fallback runs when
// machine fault tolerance is unachievable, so it is cosmetic load distribution:
// honor the imbalance threshold and defer to the global utilization phase rather than
// churning a count-balancing move that can worsen utilization (machine spreading,
// which is durability, bypasses the threshold instead).
func withinRackNodeSpread(vk volKey, r *rack, diskType string, threshold float64, dataShards int, rp *super_block.ReplicaPlacement) []*move {
	numNodes := len(r.nodes)
	gateData, gateParity := shardsByGroup(vk, r.nodes, dataShards, func(n *Node) string { return n.id })
	if !typeImbalanced(gateData, numNodes, threshold) && !typeImbalanced(gateParity, numNodes, threshold) {
		return nil
	}
	nodeShardCount := countShardsByNode(vk, r.nodes)

	dataPerNode, _ := shardsByGroup(vk, r.nodes, dataShards, func(n *Node) string { return n.id })
	moves := balanceShardTypeAcrossNodes(vk, r, diskType, dataShards,
		dataPerNode, nodeShardCount, ceilDivide(sumLens(dataPerNode), numNodes), nil, rp)

	dataPerNode, parityPerNode := shardsByGroup(vk, r.nodes, dataShards, func(n *Node) string { return n.id })
	antiAffinity := make(map[string]bool)
	for nodeID, shards := range dataPerNode {
		if len(shards) > 0 {
			antiAffinity[nodeID] = true
		}
	}
	return append(moves, balanceShardTypeAcrossNodes(vk, r, diskType, dataShards,
		parityPerNode, nodeShardCount, ceilDivide(sumLens(parityPerNode), numNodes), antiAffinity, rp)...)
}

// balanceShardTypeAcrossNodes spreads one shard type of a volume across a rack's
// nodes, moving from nodes over maxPerNode to under-loaded ones.
func balanceShardTypeAcrossNodes(vk volKey, r *rack, diskType string, dataShards int, shardsPerNode map[string][]int, nodeShardCount map[string]int, maxPerNode int, antiAffinity map[string]bool, rp *super_block.ReplicaPlacement) []*move {
	if maxPerNode < 1 {
		maxPerNode = 1
	}
	nodeKeys := sortedNodeKeys(r.nodes)

	type pending struct {
		shardID int
		src     *Node
	}
	var toMove []pending
	for _, nodeID := range nodeKeys {
		shards := append([]int(nil), shardsPerNode[nodeID]...)
		if len(shards) <= maxPerNode {
			continue
		}
		sort.Ints(shards)
		src := r.nodes[nodeID]
		for i := 0; i < len(shards)-maxPerNode; i++ {
			toMove = append(toMove, pending{shards[i], src})
		}
	}

	var moves []*move
	for _, pm := range toMove {
		destID, ok := pickTarget(nodeKeys, shardsPerNode, maxPerNode, antiAffinity,
			func(n string) bool { return n != pm.src.id && r.nodes[n].freeSlots > 0 },
			func(n string) bool {
				if rp != nil && rp.SameRackCount > 0 {
					return nodeShardCount[n] < rp.SameRackCount
				}
				return true
			})
		if !ok {
			continue
		}
		destNode := r.nodes[destID]
		destDisk := pickBestDiskOnNode(destNode, vk, diskType, pm.shardID, dataShards)
		moves = append(moves, &move{
			volumeID:   vk.vid,
			shardID:    pm.shardID,
			collection: vk.collection,
			source:     pm.src,
			sourceDisk: shardDiskID(pm.src, vk, pm.shardID),
			target:     destNode,
			targetDisk: destDisk,
			phase:      "within_rack",
		})
		releaseShard(pm.src, vk, pm.shardID)
		reserveShard(destNode, vk, pm.shardID, destDisk)
		shardsPerNode[destID] = append(shardsPerNode[destID], pm.shardID)
		shardsPerNode[pm.src.id] = removeInt(shardsPerNode[pm.src.id], pm.shardID)
		nodeShardCount[destID]++
		nodeShardCount[pm.src.id]--
		pm.src.freeSlots++
		destNode.freeSlots--
	}
	return moves
}

// detectGlobalImbalance balances total EC shard load across the nodes of each
// rack (across all volumes), using utilization ratios so heterogeneous-capacity
// nodes are compared fairly.
func detectGlobalImbalance(nodes map[string]*Node, racks map[string]*rack, diskType string, threshold float64, dataShardsByVolume, parityShardsByVolume map[volKey]int, maxMovesPerRack int, byUtilization bool) []*move {
	var moves []*move

	for _, rackID := range sortedKeys(racks) {
		r := racks[rackID]
		if len(r.nodes) <= 1 {
			continue
		}
		rackMachineCount := len(buildMachines(r))

		nodeShardCounts := make(map[string]int)
		totalShards := 0
		for nodeID, node := range r.nodes {
			count := 0
			for _, info := range node.shards {
				count += info.shardBits.Count()
			}
			nodeShardCounts[nodeID] = count
			totalShards += count
		}
		if totalShards == 0 {
			continue
		}

		// The balance metric is shards/capacity. For utilization balancing the
		// capacity is the node's real shard-slot capacity; for raw-count balancing
		// it is a constant 1, so the metric reduces to the raw shard count. Either
		// way a node can only receive while its real freeSlots remain.
		nodeCapacity := make(map[string]int, len(r.nodes))
		for nodeID, count := range nodeShardCounts {
			if byUtilization {
				nodeCapacity[nodeID] = count + r.nodes[nodeID].freeSlots
			} else {
				nodeCapacity[nodeID] = 1
			}
		}
		if !exceedsUtilImbalanceThreshold(nodeShardCounts, nodeCapacity, threshold) {
			continue
		}

		// Each iteration moves one shard. 0 means unlimited (drain to balance in
		// one pass) — bounded by totalShards since the convergence guard stops
		// once no beneficial move remains.
		iterations := maxMovesPerRack
		if iterations <= 0 {
			iterations = totalShards
		}
		for i := 0; i < iterations; i++ {
			var minNode, maxNode *Node
			minUtil := math.Inf(1)
			maxUtil := -1.0
			var minCount, maxCount int
			for _, nodeID := range sortedNodeKeys(r.nodes) {
				count := nodeShardCounts[nodeID]
				node := r.nodes[nodeID]
				capacity := nodeCapacity[nodeID]
				if capacity <= 0 {
					continue
				}
				util := float64(count) / float64(capacity)
				if util < minUtil && node.freeSlots > 0 {
					minUtil, minCount, minNode = util, count, node
				}
				if util > maxUtil {
					maxUtil, maxCount, maxNode = util, count, node
				}
			}
			if maxNode == nil || minNode == nil || maxNode.id == minNode.id {
				break
			}

			maxCap := nodeCapacity[maxNode.id]
			minCap := nodeCapacity[minNode.id]
			if maxCap <= 0 || minCap <= 0 {
				break
			}
			if float64(minCount+1)/float64(minCap) > float64(maxCount-1)/float64(maxCap) {
				break
			}

			// Prefer a volume absent from the destination's machine (pass 0) before
			// adding to one already there (pass 1), to keep volumes spread.
			moved := false
			for pass := 0; pass < 2 && !moved; pass++ {
				for _, vk := range sortedVolumeKeys(maxNode.shards) {
					if moved {
						break
					}
					info := maxNode.shards[vk]
					minInfo := minNode.shards[vk]
					volumeOnMinMachine := machineHoldsVolume(r, minNode.host, vk)
					if pass == 0 && volumeOnMinMachine {
						continue // pass 0: only volumes absent from the destination machine
					}
					if pass == 1 {
						if !volumeOnMinMachine {
							continue
						}
						// Protect the volume's machine spread only where it's achievable
						// (enough machines for this rack's shards to each stay within
						// parity); there a cross-machine load move is allowed only if it
						// doesn't raise the destination machine's count past the source's.
						// Where it isn't achievable, capacity rules and any leveling move
						// is fine. Feasibility uses the rack's shards, not the whole volume.
						parity := parityShardsByVolume[vk]
						spreadFeasible := parity > 0 && rackMachineCount >= ceilDivide(rackVolumeShardCount(r, vk), parity)
						if spreadFeasible && minNode.host != maxNode.host &&
							machineVolumeCount(r, minNode.host, vk) >= machineVolumeCount(r, maxNode.host, vk) {
							continue
						}
					}
					// Walk the volume's actual shard bitmap so custom ratios with more
					// than the standard total (ids 14..MaxShardCount-1) are candidates too.
					for sid := range info.shardBits.All() {
						shardID := int(sid)
						if minInfo != nil && minInfo.shardBits.Has(sid) {
							continue
						}
						dataShards := dataShardsByVolume[vk]
						if dataShards <= 0 {
							dataShards = erasure_coding.DataShardsCount
						}
						destDisk := pickBestDiskOnNode(minNode, vk, diskType, shardID, dataShards)
						moves = append(moves, &move{
							volumeID:   vk.vid,
							shardID:    shardID,
							collection: vk.collection,
							source:     maxNode,
							sourceDisk: shardDiskID(maxNode, vk, shardID),
							target:     minNode,
							targetDisk: destDisk,
							phase:      "global",
						})
						info.shardBits = info.shardBits.Clear(sid)
						for diskID := range info.diskShardBits {
							info.diskShardBits[diskID] = info.diskShardBits[diskID].Clear(sid)
						}
						reserveShard(minNode, vk, shardID, destDisk)
						nodeShardCounts[maxNode.id]--
						nodeShardCounts[minNode.id]++
						maxNode.freeSlots++
						minNode.freeSlots--
						moved = true
						break
					}
				}
			}
			if !moved {
				break
			}
		}
	}

	return moves
}

// shardsByGroup classifies a volume's shards into data (id < dataShards) and
// parity buckets, grouped by key(node).
func shardsByGroup(vk volKey, nodes map[string]*Node, dataShards int, key func(*Node) string) (dataPer, parityPer map[string][]int) {
	dataPer = make(map[string][]int)
	parityPer = make(map[string][]int)
	for _, node := range nodes {
		info, ok := node.shards[vk]
		if !ok {
			continue
		}
		k := key(node)
		for sid := range info.shardBits.All() {
			s := int(sid)
			if s < dataShards {
				dataPer[k] = append(dataPer[k], s)
			} else {
				parityPer[k] = append(parityPer[k], s)
			}
		}
	}
	return
}

// pickTarget selects a destination key with room for another shard of a type, in
// two passes: first excluding anti-affinity targets, then any valid target. Among
// valid targets it prefers the fewest shards of this type; ties break on sorted
// key order, so selection is deterministic.
func pickTarget(candidates []string, shardsPerTarget map[string][]int, maxPerTarget int, antiAffinity map[string]bool, hasFreeSlots, withinLimit func(string) bool) (string, bool) {
	try := func(skipAnti bool) (string, bool) {
		best := ""
		bestCount := maxPerTarget + 1
		for _, c := range candidates {
			if skipAnti && antiAffinity[c] {
				continue
			}
			if !hasFreeSlots(c) {
				continue
			}
			if len(shardsPerTarget[c]) >= maxPerTarget {
				continue
			}
			if !withinLimit(c) {
				continue
			}
			if cnt := len(shardsPerTarget[c]); cnt < bestCount {
				best, bestCount = c, cnt
			}
		}
		return best, best != ""
	}
	if len(antiAffinity) > 0 {
		if t, ok := try(true); ok {
			return t, true
		}
	}
	return try(false)
}

// pickBestDiskOnNode chooses the physical disk to place a new shard of the
// volume: matching disk type with free capacity, preferring fewer total shards,
// far fewer shards of the same volume, and data/parity anti-affinity. Returns 0
// ("server picks") when no disk info or no disk with capacity.
func pickBestDiskOnNode(node *Node, vk volKey, diskType string, shardID, dataShardCount int) uint32 {
	if len(node.disks) == 0 {
		return 0
	}
	isDataShard := dataShardCount > 0 && shardID < dataShardCount
	info := node.shards[vk]

	var bestDiskID uint32
	bestScore := -1
	for _, diskID := range sortedDiskKeys(node.disks) {
		d := node.disks[diskID]
		if diskType != "" && d.diskType != diskType {
			continue
		}
		if d.freeSlots <= 0 {
			continue
		}

		existingShards := 0
		hasData := false
		hasParity := false
		if info != nil {
			bits := info.diskShardBits[diskID]
			existingShards = bits.Count()
			if dataShardCount > 0 {
				for sid := range bits.All() {
					if int(sid) < dataShardCount {
						hasData = true
					} else {
						hasParity = true
					}
				}
			}
		}

		score := d.shardCount*10 + existingShards*100
		if dataShardCount > 0 {
			if isDataShard && hasParity {
				score += 1000
			} else if !isDataShard && hasData {
				score += 1000
			}
		}
		if bestScore == -1 || score < bestScore {
			bestScore = score
			bestDiskID = diskID
		}
	}
	return bestDiskID
}

// shardDiskID returns the disk holding the given shard of the volume, or 0.
func shardDiskID(node *Node, vk volKey, shardID int) uint32 {
	info, ok := node.shards[vk]
	if !ok {
		return 0
	}
	sid := erasure_coding.ShardId(shardID)
	for _, diskID := range sortedDiskKeys(info.diskShardBits) {
		if info.diskShardBits[diskID].Has(sid) {
			return diskID
		}
	}
	return 0
}

// reserveShard records a just-planned placement on the destination so later picks
// in the same run spread across disks/nodes.
func reserveShard(node *Node, vk volKey, shardID int, diskID uint32) {
	info, ok := node.shards[vk]
	if !ok {
		info = &volumeShards{collection: vk.collection, diskShardBits: make(map[uint32]erasure_coding.ShardBits)}
		node.shards[vk] = info
	}
	if info.diskShardBits == nil {
		info.diskShardBits = make(map[uint32]erasure_coding.ShardBits)
	}
	sid := erasure_coding.ShardId(shardID)
	info.shardBits = info.shardBits.Set(sid)
	info.diskShardBits[diskID] = info.diskShardBits[diskID].Set(sid)
	if d, ok := node.disks[diskID]; ok {
		d.shardCount++
		// Decrement unconditionally so reserve/release stay symmetric (releaseShard
		// credits a slot unconditionally). Callers only reserve onto disks
		// pickBestDisk* already vetted as having free slots, so this won't go
		// negative; if it ever did, freeSlots<=0 correctly reads as full.
		d.freeSlots--
	}
}

// releaseShard removes a shard of the volume from a node's model.
func releaseShard(node *Node, vk volKey, shardID int) {
	info, ok := node.shards[vk]
	if !ok {
		return
	}
	sid := erasure_coding.ShardId(shardID)
	for diskID, bits := range info.diskShardBits {
		if bits.Has(sid) {
			info.diskShardBits[diskID] = bits.Clear(sid)
			if d, ok := node.disks[diskID]; ok {
				d.shardCount--
				d.freeSlots++
			}
		}
	}
	info.shardBits = info.shardBits.Clear(sid)
}

// applyMovesToTopology simulates moves so later phases see updated placement.
// Dedup moves (source==target) are deletions that this helper alone applies, so
// it also credits the freed disk/node/rack capacity — otherwise a slot opened by
// dedup could not be used by the cross-rack/within-rack/global phases in the same
// run. Non-dedup moves already had their slots accounted inline by the phase that
// produced them, so only their shard bits are (idempotently) re-asserted here.
func applyMovesToTopology(moves []*move, racks map[string]*rack) {
	for _, m := range moves {
		sid := erasure_coding.ShardId(m.shardID)
		vk := volKey{collection: m.collection, vid: m.volumeID}
		dedup := m.source.id == m.target.id

		if srcInfo, ok := m.source.shards[vk]; ok {
			srcInfo.shardBits = srcInfo.shardBits.Clear(sid)
			for diskID := range srcInfo.diskShardBits {
				if !srcInfo.diskShardBits[diskID].Has(sid) {
					continue
				}
				srcInfo.diskShardBits[diskID] = srcInfo.diskShardBits[diskID].Clear(sid)
				if dedup {
					if d, ok := m.source.disks[diskID]; ok {
						d.shardCount--
						d.freeSlots++
					}
				}
			}
		}

		if dedup {
			m.source.freeSlots++
			if r, ok := racks[m.source.rack]; ok {
				r.freeSlots++
			}
			continue
		}

		dstInfo, ok := m.target.shards[vk]
		if !ok {
			dstInfo = &volumeShards{collection: m.collection, diskShardBits: make(map[uint32]erasure_coding.ShardBits)}
			m.target.shards[vk] = dstInfo
		}
		if dstInfo.diskShardBits == nil {
			dstInfo.diskShardBits = make(map[uint32]erasure_coding.ShardBits)
		}
		dstInfo.shardBits = dstInfo.shardBits.Set(sid)
		dstInfo.diskShardBits[m.targetDisk] = dstInfo.diskShardBits[m.targetDisk].Set(sid)
	}
}

func volumeShardCount(node *Node, vk volKey) int {
	if info, ok := node.shards[vk]; ok {
		return info.shardBits.Count()
	}
	return 0
}

func nodeInRackHoldingShard(nodes map[string]*Node, rackID string, vk volKey, shardID int) *Node {
	var inRack []*Node
	for _, id := range sortedNodeKeys(nodes) {
		if nodes[id].rack == rackID {
			inRack = append(inRack, nodes[id])
		}
	}
	return nodeHoldingShard(inRack, vk, shardID)
}

// nodeHoldingShard returns the first node holding the given shard of the volume, or nil.
func nodeHoldingShard(nodes []*Node, vk volKey, shardID int) *Node {
	sid := erasure_coding.ShardId(shardID)
	for _, node := range nodes {
		if info, ok := node.shards[vk]; ok && info.shardBits.Has(sid) {
			return node
		}
	}
	return nil
}

// buildMachines groups a rack's nodes by host, each slice sorted by node id.
func buildMachines(r *rack) map[string][]*Node {
	machines := make(map[string][]*Node)
	for _, n := range sortedNodeSlice(r.nodes) {
		machines[n.host] = append(machines[n.host], n)
	}
	return machines
}

// rackVolumeShardCount returns how many of the volume's shards the whole rack holds.
func rackVolumeShardCount(r *rack, vk volKey) int {
	count := 0
	for _, n := range r.nodes {
		count += volumeShardCount(n, vk)
	}
	return count
}

// machineVolumeCount returns how many of the volume's shards the machine (host) holds.
func machineVolumeCount(r *rack, host string, vk volKey) int {
	count := 0
	for _, n := range r.nodes {
		if n.host != host {
			continue
		}
		if info, ok := n.shards[vk]; ok {
			count += info.shardBits.Count()
		}
	}
	return count
}

// machineHoldsVolume reports whether any node on the machine holds a shard of the volume.
func machineHoldsVolume(r *rack, host string, vk volKey) bool {
	return machineVolumeCount(r, host, vk) > 0
}

func sortedNodeSlice(nodes map[string]*Node) []*Node {
	ids := sortedNodeKeys(nodes)
	out := make([]*Node, 0, len(ids))
	for _, id := range ids {
		out = append(out, nodes[id])
	}
	return out
}

func countShardsByRack(vk volKey, nodes map[string]*Node) map[string]int {
	m := make(map[string]int)
	for _, node := range nodes {
		if info, ok := node.shards[vk]; ok {
			m[node.rack] += info.shardBits.Count()
		}
	}
	return m
}

func countShardsByHost(vk volKey, nodes map[string]*Node) map[string]int {
	m := make(map[string]int)
	for _, node := range nodes {
		if info, ok := node.shards[vk]; ok {
			m[node.host] += info.shardBits.Count()
		}
	}
	return m
}

// freeSlotsByHost sums each machine's free EC shard slots.
func freeSlotsByHost(nodes map[string]*Node) map[string]int {
	m := make(map[string]int)
	for _, node := range nodes {
		m[node.host] += node.freeSlots
	}
	return m
}

func countShardsByNode(vk volKey, nodes map[string]*Node) map[string]int {
	m := make(map[string]int)
	for id, node := range nodes {
		if info, ok := node.shards[vk]; ok {
			m[id] = info.shardBits.Count()
		}
	}
	return m
}

func sortedKeys[T any](m map[string]T) []string {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	return keys
}

func sortedNodeKeys(nodes map[string]*Node) []string {
	return sortedKeys(nodes)
}

func sortedDiskKeys[T any](m map[uint32]T) []uint32 {
	keys := make([]uint32, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
	return keys
}

func sortedVolumeKeys(m map[volKey]*volumeShards) []volKey {
	keys := make([]volKey, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Slice(keys, func(i, j int) bool {
		if keys[i].vid != keys[j].vid {
			return keys[i].vid < keys[j].vid
		}
		return keys[i].collection < keys[j].collection
	})
	return keys
}

func sumLens(m map[string][]int) int {
	total := 0
	for _, v := range m {
		total += len(v)
	}
	return total
}

func removeInt(s []int, v int) []int {
	for i, x := range s {
		if x == v {
			return append(s[:i], s[i+1:]...)
		}
	}
	return s
}

func ceilDivide(a, b int) int {
	if b == 0 {
		return 0
	}
	return (a + b - 1) / b
}

// typeImbalanced reports whether the shards of one type (data or parity),
// grouped by rack or node, are spread unevenly enough across numGroups to exceed
// the threshold. Gating per type (rather than on combined totals) ensures a
// data/parity skew is acted on even when the per-group totals are even.
func typeImbalanced(perGroup map[string][]int, numGroups int, threshold float64) bool {
	counts := make(map[string]int, len(perGroup))
	total := 0
	for k, v := range perGroup {
		counts[k] = len(v)
		total += len(v)
	}
	if total == 0 {
		return false
	}
	return exceedsImbalanceThreshold(counts, total, numGroups, threshold)
}

// exceedsImbalanceThreshold reports whether (max-min)/avg over numGroups exceeds
// the threshold. Groups missing from counts are treated as zero.
func exceedsImbalanceThreshold(counts map[string]int, total, numGroups int, threshold float64) bool {
	if numGroups <= 1 || total == 0 {
		return false
	}
	minCount := 0
	if len(counts) >= numGroups {
		minCount = total + 1
		for _, count := range counts {
			if count < minCount {
				minCount = count
			}
		}
	}
	maxCount := -1
	for _, count := range counts {
		if count > maxCount {
			maxCount = count
		}
	}
	avg := float64(total) / float64(numGroups)
	if avg == 0 {
		return false
	}
	return float64(maxCount-minCount)/avg > threshold
}

// exceedsUtilImbalanceThreshold compares fractional fullness (count/capacity) so
// heterogeneous-capacity nodes are evaluated fairly.
func exceedsUtilImbalanceThreshold(counts, capacities map[string]int, threshold float64) bool {
	minUtil := math.Inf(1)
	maxUtil := -1.0
	seen := 0
	for nodeID, count := range counts {
		capacity := capacities[nodeID]
		if capacity <= 0 {
			continue
		}
		util := float64(count) / float64(capacity)
		if util < minUtil {
			minUtil = util
		}
		if util > maxUtil {
			maxUtil = util
		}
		seen++
	}
	if seen < 2 || maxUtil <= 0 {
		return false
	}
	avg := (maxUtil + minUtil) / 2
	if avg == 0 {
		return false
	}
	return (maxUtil-minUtil)/avg > threshold
}
