package ec_balance

import (
	"context"
	"sort"
	"testing"

	"github.com/seaweedfs/seaweedfs/weed/admin/topology"
	"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
	"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
	"github.com/seaweedfs/seaweedfs/weed/worker/types"
)

// In-process (no real cluster) tests of the detection/planning path against a
// topology shaped the way the master reports a multi-disk cluster: several
// same-type physical disks on a node collapse into a single DiskInfo, with each
// shard's real DiskId surviving only in the per-shard records (issue 9593). They
// run the master-wire-format -> ActiveTopology -> Detection path, then simulate
// executing the planned moves with the volume server's actual semantics
// (VolumeEcShardsDelete is node-wide) to assert no EC shard is ever lost. The
// real-cluster end-to-end equivalent lives in test/erasure_coding.

// nodeSpec describes one volume server: its rack and the volume's shards per
// physical disk (diskID -> shard ids).
type nodeSpec struct {
	id    string
	rack  string
	disks map[uint32][]int
}

const integDisksPerNode = 6

// buildMasterTopology renders nodeSpecs into a *master_pb.TopologyInfo exactly as
// the master would: one DiskInfo per node keyed by disk type (""), carrying one
// EcShardInfo per (volume, physical disk) plus regular VolumeInfos so every
// physical disk is discoverable even when it holds no EC shards.
func buildMasterTopology(collection string, vid uint32, maxVolPerDisk int, specs []nodeSpec) *master_pb.TopologyInfo {
	rackByID := map[string]*master_pb.RackInfo{}
	var rackOrder []string

	for _, spec := range specs {
		var ecShards []*master_pb.VolumeEcShardInformationMessage
		for diskID, shards := range spec.disks {
			var bits erasure_coding.ShardBits
			for _, s := range shards {
				bits = bits.Set(erasure_coding.ShardId(s))
			}
			ecShards = append(ecShards, &master_pb.VolumeEcShardInformationMessage{
				Id:          vid,
				Collection:  collection,
				DiskId:      diskID,
				EcIndexBits: uint32(bits),
			})
		}
		// Expose all physical disks (incl. EC-empty ones) via regular volumes.
		var volInfos []*master_pb.VolumeInformationMessage
		for d := uint32(0); d < integDisksPerNode; d++ {
			volInfos = append(volInfos, &master_pb.VolumeInformationMessage{Id: 90000 + d, DiskId: d})
		}
		dn := &master_pb.DataNodeInfo{
			Id: spec.id,
			DiskInfos: map[string]*master_pb.DiskInfo{
				"": {
					Type:           "",
					MaxVolumeCount: int64(maxVolPerDisk * integDisksPerNode),
					VolumeCount:    int64(integDisksPerNode),
					EcShardInfos:   ecShards,
					VolumeInfos:    volInfos,
				},
			},
		}
		r, ok := rackByID[spec.rack]
		if !ok {
			r = &master_pb.RackInfo{Id: spec.rack}
			rackByID[spec.rack] = r
			rackOrder = append(rackOrder, spec.rack)
		}
		r.DataNodeInfos = append(r.DataNodeInfos, dn)
	}

	dc := &master_pb.DataCenterInfo{Id: "dc1"}
	for _, rk := range rackOrder {
		dc.RackInfos = append(dc.RackInfos, rackByID[rk])
	}
	return &master_pb.TopologyInfo{Id: "integ", DataCenterInfos: []*master_pb.DataCenterInfo{dc}}
}

// runDetection wires the topology through a real ActiveTopology and runs Detection.
func runDetection(t *testing.T, topoInfo *master_pb.TopologyInfo, cfg *Config) []*types.TaskDetectionResult {
	t.Helper()
	at := topology.NewActiveTopology(0)
	if err := at.UpdateTopology(topoInfo); err != nil {
		t.Fatalf("UpdateTopology: %v", err)
	}
	results, _, err := Detection(context.Background(), nil, &types.ClusterInfo{ActiveTopology: at}, cfg, 0)
	if err != nil {
		t.Fatalf("Detection: %v", err)
	}
	return results
}

// shardModel is nodeID -> diskID -> set of shard ids for one volume.
type shardModel map[string]map[uint32]map[int]bool

func modelFromSpecs(specs []nodeSpec) shardModel {
	m := make(shardModel)
	for _, spec := range specs {
		m[spec.id] = make(map[uint32]map[int]bool)
		for diskID, shards := range spec.disks {
			set := make(map[int]bool)
			for _, s := range shards {
				set[s] = true
			}
			m[spec.id][diskID] = set
		}
	}
	return m
}

func (m shardModel) distinctShards() []int {
	seen := map[int]bool{}
	for _, disks := range m {
		for _, set := range disks {
			for s := range set {
				seen[s] = true
			}
		}
	}
	out := make([]int, 0, len(seen))
	for s := range seen {
		out = append(out, s)
	}
	sort.Ints(out)
	return out
}

// applyMovesRealistically replays planned moves with the volume server's actual
// behavior: a move copies the shard to the destination disk, then deletes it on
// the source node — and EC shard delete is node-wide (removes the shard from
// every disk on the source node). A dedup move (same node+disk) is delete-only.
func (m shardModel) apply(results []*types.TaskDetectionResult) {
	nodeWideDelete := func(node string, shard int) {
		for diskID := range m[node] {
			delete(m[node][diskID], shard)
		}
	}
	for _, r := range results {
		p := r.TypedParams
		if p == nil || len(p.Sources) == 0 || len(p.Targets) == 0 {
			continue
		}
		src, dst := p.Sources[0], p.Targets[0]
		if len(src.ShardIds) == 0 {
			continue
		}
		shard := int(src.ShardIds[0])
		dedup := src.Node == dst.Node && src.DiskId == dst.DiskId
		if !dedup {
			if m[dst.Node] == nil {
				m[dst.Node] = make(map[uint32]map[int]bool)
			}
			if m[dst.Node][dst.DiskId] == nil {
				m[dst.Node][dst.DiskId] = make(map[int]bool)
			}
			m[dst.Node][dst.DiskId][shard] = true
		}
		nodeWideDelete(src.Node, shard)
	}
}

// TestMultiDiskBalanceNeverLosesShards is the core regression for
// issue 9593: a freshly-encoded volume on a 3-node, 6-disk-per-node cluster,
// balanced and then concentrated, must never lose a shard when the planned moves
// are executed with real node-wide-delete semantics.
func TestMultiDiskBalanceNeverLosesShards(t *testing.T) {
	cfg := NewDefaultConfig()
	cfg.ImbalanceThreshold = 0.0 // balance to even; surface any move the planner makes

	cases := []struct {
		name  string
		specs []nodeSpec
	}{
		{
			// Healthy post-encode spread: 14 shards across 3 nodes (5/5/4), each
			// node spreading its shards over distinct physical disks.
			name: "balanced across disks",
			specs: []nodeSpec{
				{id: "n1", rack: "rack1", disks: map[uint32][]int{0: {0}, 1: {1}, 2: {2}, 3: {3}, 4: {4}}},
				{id: "n2", rack: "rack1", disks: map[uint32][]int{0: {5}, 1: {6}, 2: {7}, 3: {8}, 4: {9}}},
				{id: "n3", rack: "rack1", disks: map[uint32][]int{0: {10}, 1: {11}, 2: {12}, 3: {13}}},
			},
		},
		{
			// Worst case from the report: all 14 shards landed on one node's disks;
			// the balancer must redistribute without losing any.
			name: "concentrated on one node",
			specs: []nodeSpec{
				{id: "n1", rack: "rack1", disks: map[uint32][]int{0: {0, 1, 2}, 1: {3, 4, 5}, 2: {6, 7}, 3: {8, 9}, 4: {10, 11}, 5: {12, 13}}},
				{id: "n2", rack: "rack2", disks: map[uint32][]int{}},
				{id: "n3", rack: "rack3", disks: map[uint32][]int{}},
			},
		},
		{
			// Multi-rack healthy spread.
			name: "spread across racks",
			specs: []nodeSpec{
				{id: "n1", rack: "rack1", disks: map[uint32][]int{0: {0, 1}, 1: {2, 3}, 2: {4}}},
				{id: "n2", rack: "rack2", disks: map[uint32][]int{0: {5, 6}, 1: {7, 8}, 2: {9}}},
				{id: "n3", rack: "rack3", disks: map[uint32][]int{0: {10, 11}, 1: {12, 13}}},
			},
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			topoInfo := buildMasterTopology("col1", 28, 50, tc.specs)
			results := runDetection(t, topoInfo, cfg)

			model := modelFromSpecs(tc.specs)
			before := model.distinctShards()

			for _, r := range results {
				// No move may be a same-node cross-disk move (node-wide delete
				// would erase the shard after a skipped copy).
				p := r.TypedParams
				s, d := p.Sources[0], p.Targets[0]
				if s.Node == d.Node && s.DiskId != d.DiskId {
					t.Errorf("unsafe same-node cross-disk move: vol %d shard %v on %s disk %d->%d", p.VolumeId, s.ShardIds, s.Node, s.DiskId, d.DiskId)
				}
				// Source disk must actually hold the shard being moved.
				shard := int(s.ShardIds[0])
				if !model[s.Node][s.DiskId][shard] && s.Node != d.Node {
					t.Errorf("move sources vol %d shard %d from %s disk %d, which does not hold it", p.VolumeId, shard, s.Node, s.DiskId)
				}
			}

			model.apply(results)
			after := model.distinctShards()

			if len(after) != len(before) {
				t.Errorf("[%s] shard loss: had %v (%d), now %v (%d) after %d moves",
					tc.name, before, len(before), after, len(after), len(results))
			}
		})
	}
}

// TestConcentratedVolumeSpreadsAcrossNodesAndDisks asserts the
// remediation actually happens: a one-node-concentrated volume is redistributed
// to the other racks, landing on multiple distinct destination disks.
func TestConcentratedVolumeSpreadsAcrossNodesAndDisks(t *testing.T) {
	cfg := NewDefaultConfig()
	cfg.ImbalanceThreshold = 0.0

	specs := []nodeSpec{
		{id: "n1", rack: "rack1", disks: map[uint32][]int{0: {0, 1, 2}, 1: {3, 4, 5}, 2: {6, 7}, 3: {8, 9}, 4: {10, 11}, 5: {12, 13}}},
		{id: "n2", rack: "rack2", disks: map[uint32][]int{}},
		{id: "n3", rack: "rack3", disks: map[uint32][]int{}},
	}
	results := runDetection(t, buildMasterTopology("col1", 28, 50, specs), cfg)
	if len(results) == 0 {
		t.Fatal("expected redistribution moves for a one-node-concentrated volume")
	}

	destNodes := map[string]bool{}
	destDisksByNode := map[string]map[uint32]bool{}
	for _, r := range results {
		d := r.TypedParams.Targets[0]
		if d.Node == "n1" {
			continue
		}
		destNodes[d.Node] = true
		if destDisksByNode[d.Node] == nil {
			destDisksByNode[d.Node] = map[uint32]bool{}
		}
		destDisksByNode[d.Node][d.DiskId] = true
	}
	if len(destNodes) < 2 {
		t.Errorf("shards spread to only %d destination nodes, want both other racks", len(destNodes))
	}
	for node, disks := range destDisksByNode {
		if len(disks) < 2 {
			t.Errorf("destination %s received shards on only %d disk(s); expected spread across disks: %v", node, len(disks), disks)
		}
	}
}

// TestBuildBalancerTopologyNormalizesHddDiskType guards the disk-type filter:
// the master reports default-HDD disks under the empty-string key, so a config of
// "hdd" must match them (not filter everything out), while "ssd" must exclude them.
func TestBuildBalancerTopologyNormalizesHddDiskType(t *testing.T) {
	specs := []nodeSpec{
		{id: "n1", rack: "r1", disks: map[uint32][]int{0: {0, 1}}},
		{id: "n2", rack: "r1", disks: map[uint32][]int{0: {2}}},
	}
	topoInfo := buildMasterTopology("c", 100, 50, specs)

	if _, n, _ := buildBalancerTopology(topoInfo, &Config{DiskType: "hdd"}, nil); n != 2 {
		t.Errorf("disk_type=hdd matched %d nodes on an all-HDD cluster, want 2 (hdd must map to the empty HDD key)", n)
	}
	if _, n, _ := buildBalancerTopology(topoInfo, &Config{DiskType: ""}, nil); n != 2 {
		t.Errorf("disk_type=empty matched %d nodes, want 2 (all)", n)
	}
	if _, n, _ := buildBalancerTopology(topoInfo, &Config{DiskType: "ssd"}, nil); n != 0 {
		t.Errorf("disk_type=ssd matched %d nodes on an all-HDD cluster, want 0", n)
	}
}

// TestResolveReplicaPlacementFallsBackToMasterDefault verifies the worker mirrors
// the shell: explicit config wins, otherwise the master's default replication is
// the fallback, and an empty or zero-replication value means no constraint.
func TestResolveReplicaPlacementFallsBackToMasterDefault(t *testing.T) {
	cases := []struct {
		name        string
		configRP    string
		defaultRP   string
		wantApplied bool
	}{
		{"explicit config used", "010", "", true},
		{"explicit config wins over default", "010", "100", true},
		{"falls back to master default", "", "010", true},
		{"zero master default = no constraint", "", "000", false},
		{"empty everywhere = no constraint", "", "", false},
		{"invalid value ignored", "", "nonsense", false},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			rp := resolveReplicaPlacement(&Config{ReplicaPlacement: tc.configRP},
				&types.ClusterInfo{DefaultReplicaPlacement: tc.defaultRP})
			if (rp != nil) != tc.wantApplied {
				t.Errorf("config=%q default=%q: applied=%v, want %v", tc.configRP, tc.defaultRP, rp != nil, tc.wantApplied)
			}
		})
	}
}
