package placement

import (
	"testing"

	"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
	"github.com/seaweedfs/seaweedfs/weed/storage/types"
)

func placementNode(id string, maxVolumes, used int64, totalBytes, freeBytes uint64) *master_pb.DataNodeInfo {
	return &master_pb.DataNodeInfo{Id: id, DiskInfos: map[string]*master_pb.DiskInfo{
		"ssd": {
			MaxVolumeCount: maxVolumes, VolumeCount: used,
			DiskTotalBytes: totalBytes, DiskFreeBytes: freeBytes,
		},
	}}
}

// placementTopo builds a one-datacenter topology, taking each node's first
// character as its rack so the tests read compactly.
func placementTopo(nodes ...*master_pb.DataNodeInfo) *master_pb.TopologyInfo {
	byRack := map[string][]*master_pb.DataNodeInfo{}
	var order []string
	for _, n := range nodes {
		rack := n.Id[:1]
		if _, seen := byRack[rack]; !seen {
			order = append(order, rack)
		}
		byRack[rack] = append(byRack[rack], n)
	}
	var racks []*master_pb.RackInfo
	for _, r := range order {
		racks = append(racks, &master_pb.RackInfo{Id: r, DataNodeInfos: byRack[r]})
	}
	return &master_pb.TopologyInfo{DataCenterInfos: []*master_pb.DataCenterInfo{{Id: "dc1", RackInfos: racks}}}
}

func TestPickTargetPrefersTheEmptiestByFreeBytes(t *testing.T) {
	topo := placementTopo(
		placementNode("a1", 10, 1, 1000, 200),
		placementNode("a2", 10, 1, 1000, 900),
		placementNode("a3", 10, 1, 1000, 500),
	)
	got := PickTarget(topo, PlacementPreference{Source: "z9", DiskType: types.SsdType})
	if got.GetId() != "a2" {
		t.Fatalf("got %q, want the node with the most free bytes (a2)", got.GetId())
	}
}

func TestPickTargetFallsBackToSlotsWithoutReportedBytes(t *testing.T) {
	// A volume server too old to report filesystem bytes must still be ordered
	// sensibly rather than read as having zero free.
	topo := placementTopo(
		placementNode("a1", 10, 9, 0, 0),
		placementNode("a2", 10, 2, 0, 0),
	)
	got := PickTarget(topo, PlacementPreference{Source: "z9", DiskType: types.SsdType})
	if got.GetId() != "a2" {
		t.Fatalf("got %q, want the node with the most free slots (a2)", got.GetId())
	}
}

func TestPickTargetSkipsSourceExcludedAndFull(t *testing.T) {
	topo := placementTopo(
		placementNode("a1", 10, 1, 1000, 900), // the source itself
		placementNode("a2", 10, 1, 1000, 800), // already spoken for by the plan
		placementNode("a3", 5, 5, 1000, 700),  // no free slot
		placementNode("a4", 10, 1, 1000, 100),
	)
	got := PickTarget(topo, PlacementPreference{
		Source: "a1", DiskType: types.SsdType, Exclude: map[string]bool{"a2": true},
	})
	if got.GetId() != "a4" {
		t.Fatalf("got %q, want a4", got.GetId())
	}
}

func TestPickTargetReturnsNilWhenNothingFits(t *testing.T) {
	topo := placementTopo(placementNode("a1", 5, 5, 1000, 900))
	if got := PickTarget(topo, PlacementPreference{Source: "z9", DiskType: types.SsdType}); got != nil {
		t.Fatalf("got %q, want nil", got.GetId())
	}
}

func TestPickTargetHonoursAnchorDataCenter(t *testing.T) {
	topo := &master_pb.TopologyInfo{DataCenterInfos: []*master_pb.DataCenterInfo{
		{Id: "dc1", RackInfos: []*master_pb.RackInfo{{Id: "r1", DataNodeInfos: []*master_pb.DataNodeInfo{
			placementNode("a1", 10, 1, 1000, 100),
		}}}},
		{Id: "dc2", RackInfos: []*master_pb.RackInfo{{Id: "r2", DataNodeInfos: []*master_pb.DataNodeInfo{
			placementNode("b1", 10, 1, 1000, 900),
		}}}},
	}}
	got := PickTarget(topo, PlacementPreference{
		Source: "z9", DiskType: types.SsdType, AnchorDataCenter: "dc1",
	})
	if got.GetId() != "a1" {
		t.Fatalf("got %q, want a1 -- the anchor outranks an emptier node elsewhere", got.GetId())
	}
}

func TestPickTargetPrefersTheSourceRackOverAnEmptierStranger(t *testing.T) {
	topo := placementTopo(
		placementNode("a1", 10, 1, 1000, 500), // source, rack a
		placementNode("a2", 10, 1, 1000, 100), // same rack, tighter
		placementNode("b1", 10, 1, 1000, 900), // other rack, emptiest
	)
	got := PickTarget(topo, PlacementPreference{Source: "a1", DiskType: types.SsdType})
	if got.GetId() != "a2" {
		t.Fatalf("got %q, want a2 -- same rack outranks emptier", got.GetId())
	}
}

func TestPickTargetLeavesTheRackWhenItCannotHoldTheVolume(t *testing.T) {
	// Locality is a preference over candidates, never a reason to overfill.
	topo := placementTopo(
		placementNode("a1", 10, 1, 1000, 500), // source
		placementNode("a2", 5, 5, 1000, 900),  // same rack, no free slot
		placementNode("b1", 10, 1, 1000, 100),
	)
	got := PickTarget(topo, PlacementPreference{Source: "a1", DiskType: types.SsdType})
	if got.GetId() != "b1" {
		t.Fatalf("got %q, want b1 -- a full same-rack node is not a candidate", got.GetId())
	}
}

func TestPickTargetPrefersSameDataCenterOverAnother(t *testing.T) {
	topo := &master_pb.TopologyInfo{DataCenterInfos: []*master_pb.DataCenterInfo{
		{Id: "dc1", RackInfos: []*master_pb.RackInfo{
			{Id: "r1", DataNodeInfos: []*master_pb.DataNodeInfo{placementNode("a1", 10, 1, 1000, 500)}},
			{Id: "r2", DataNodeInfos: []*master_pb.DataNodeInfo{placementNode("a2", 10, 1, 1000, 100)}},
		}},
		{Id: "dc2", RackInfos: []*master_pb.RackInfo{
			{Id: "r3", DataNodeInfos: []*master_pb.DataNodeInfo{placementNode("b1", 10, 1, 1000, 900)}},
		}},
	}}
	got := PickTarget(topo, PlacementPreference{Source: "a1", DiskType: types.SsdType})
	if got.GetId() != "a2" {
		t.Fatalf("got %q, want a2 -- another rack in the same dc beats another dc", got.GetId())
	}
}

func TestPickTargetFallsBackToCapacityForAnUnknownSource(t *testing.T) {
	// A source that is not in the topology has no rack to be near, so ordering
	// degrades to capacity rather than to topology order.
	topo := placementTopo(
		placementNode("a1", 10, 1, 1000, 100),
		placementNode("b1", 10, 1, 1000, 900),
	)
	got := PickTarget(topo, PlacementPreference{Source: "gone:8080", DiskType: types.SsdType})
	if got.GetId() != "b1" {
		t.Fatalf("got %q, want b1", got.GetId())
	}
}

func TestPickTargetSpendsCapacitySoABatchSpreads(t *testing.T) {
	// The reservation this exists for: planning several moves from one snapshot
	// must not stack them all on whichever node started emptiest.
	topo := placementTopo(
		placementNode("a1", 4, 0, 4000, 4000),
		placementNode("a2", 4, 0, 4000, 3000),
	)
	seen := map[string]int{}
	for i := 0; i < 4; i++ {
		got := PickTarget(topo, PlacementPreference{Source: "z9", DiskType: types.SsdType})
		if got == nil {
			t.Fatalf("pick %d returned nil", i)
		}
		seen[got.GetId()]++
	}
	if seen["a1"] == 4 || seen["a2"] == 4 {
		t.Fatalf("all four picks landed on one node: %v", seen)
	}
	if seen["a1"]+seen["a2"] != 4 {
		t.Fatalf("unexpected spread: %v", seen)
	}
}

func TestPickTargetStopsWhenTheSnapshotIsSpent(t *testing.T) {
	// Reservation has to make a node stop being a candidate, not merely rank it
	// lower, or a batch could overcommit a tier.
	topo := placementTopo(placementNode("a1", 2, 0, 2000, 2000))
	for i := 0; i < 2; i++ {
		if got := PickTarget(topo, PlacementPreference{Source: "z9", DiskType: types.SsdType}); got == nil {
			t.Fatalf("pick %d returned nil while slots remained", i)
		}
	}
	if got := PickTarget(topo, PlacementPreference{Source: "z9", DiskType: types.SsdType}); got != nil {
		t.Fatalf("got %q, want nil once the snapshot is spent", got.GetId())
	}
}

func TestPickTargetUsesOneMetricWhenReportingIsMixed(t *testing.T) {
	// b1 has the most free bytes but the fewest slots; a2 the reverse. With a3
	// reporting no bytes at all, every candidate must be ordered on slots, or
	// the comparator is intransitive and the winner depends on sort order.
	topo := placementTopo(
		placementNode("a1", 10, 1, 1000, 500), // source
		placementNode("a2", 10, 1, 1000, 100),
		placementNode("a3", 10, 2, 0, 0), // too old to report bytes
		placementNode("a4", 10, 8, 1000, 900),
	)
	got := PickTarget(topo, PlacementPreference{Source: "a1", DiskType: types.SsdType})
	if got.GetId() != "a2" {
		t.Fatalf("got %q, want a2 -- the most free slots once bytes are unusable", got.GetId())
	}
}

func TestPickTargetReservesTheVolumeSize(t *testing.T) {
	// A volume far bigger than the tier average must be charged at its real
	// size, or a batch of them overcommits the destination.
	topo := placementTopo(
		placementNode("a1", 10, 0, 1000, 1000),
		placementNode("a2", 10, 0, 1000, 900),
	)
	first := PickTarget(topo, PlacementPreference{Source: "z9", DiskType: types.SsdType, VolumeBytes: 800})
	if first.GetId() != "a1" {
		t.Fatalf("first pick %q, want a1", first.GetId())
	}
	// a1 now reports 200 free against a2's 900, so the next move must go there
	// rather than to the node that merely started emptiest.
	second := PickTarget(topo, PlacementPreference{Source: "z9", DiskType: types.SsdType, VolumeBytes: 800})
	if second.GetId() != "a2" {
		t.Fatalf("second pick %q, want a2 after 800 bytes were spent on a1", second.GetId())
	}
}

func TestPickTargetHonoursTheCallerPredicate(t *testing.T) {
	// Constraints placement does not model -- replica placement, a node already
	// holding the volume -- stay with the caller, which keeps them out of the
	// ranking rather than duplicating them here.
	topo := placementTopo(
		placementNode("a1", 10, 1, 1000, 900),
		placementNode("a2", 10, 1, 1000, 500),
	)
	got := PickTarget(topo, PlacementPreference{
		Source: "z9", DiskType: types.SsdType,
		Accept: func(n *master_pb.DataNodeInfo, dc, rack string) bool { return n.Id != "a1" },
	})
	if got.GetId() != "a2" {
		t.Fatalf("got %q, want a2 -- a1 was rejected despite being emptier", got.GetId())
	}
}

func TestPickTargetPredicateSeesRackAndDataCenter(t *testing.T) {
	topo := placementTopo(placementNode("a1", 10, 1, 1000, 900))
	var sawDc, sawRack string
	PickTarget(topo, PlacementPreference{
		Source: "z9", DiskType: types.SsdType,
		Accept: func(n *master_pb.DataNodeInfo, dc, rack string) bool {
			sawDc, sawRack = dc, rack
			return true
		},
	})
	if sawDc != "dc1" || sawRack != "a" {
		t.Fatalf("predicate saw dc=%q rack=%q, want dc1/a", sawDc, sawRack)
	}
}
