package volume_server_grpc_test

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"testing"
	"time"

	"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
	"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
	"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
	"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
	"github.com/seaweedfs/seaweedfs/weed/storage/needle"
	"github.com/seaweedfs/seaweedfs/weed/storage/volume_info"
)

// TestEcLifecycleAcrossMultipleDisks drives encode, mount, read, drop-dat,
// cross-disk redistribute + restart + reconcile, blob delete, and rebuild on
// a 2-disk volume server.
func TestEcLifecycleAcrossMultipleDisks(t *testing.T) {
	if testing.Short() {
		t.Skip("skipping integration test in short mode")
	}

	const (
		dataDirCount = 2
		volumeID     = uint32(9500)
		collection   = "ec-multi-disk"
	)

	clusterHarness := framework.StartSingleVolumeClusterWithDataDirs(t, matrix.P1(), dataDirCount)
	dataDirs := clusterHarness.VolumeDataDirs()
	if len(dataDirs) != dataDirCount {
		t.Fatalf("expected %d data dirs, got %d: %v", dataDirCount, len(dataDirs), dataDirs)
	}

	conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
	defer conn.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()

	framework.AllocateVolume(t, grpcClient, volumeID, collection)

	// populate
	type needleSpec struct {
		fid     string
		payload []byte
	}
	needles := []needleSpec{
		{framework.NewFileID(volumeID, 9501, 0xAA000001), bytesOfLen(11, 0xA1)},
		{framework.NewFileID(volumeID, 9502, 0xAA000002), bytesOfLen(1024, 0xA2)},
		{framework.NewFileID(volumeID, 9503, 0xAA000003), bytesOfLen(63*1024+17, 0xA3)},
		{framework.NewFileID(volumeID, 9504, 0xAA000004), bytesOfLen(2, 0xA4)},
		{framework.NewFileID(volumeID, 9505, 0xAA000005), bytesOfLen(513*1024, 0xA5)},
	}
	httpClient := framework.NewHTTPClient()
	for _, n := range needles {
		resp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), n.fid, n.payload)
		_ = framework.ReadAllAndClose(t, resp)
		if resp.StatusCode != http.StatusCreated {
			t.Fatalf("upload %s expected 201, got %d", n.fid, resp.StatusCode)
		}
	}

	// encode — every shard lands on the .dat's disk
	if _, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
		VolumeId:   volumeID,
		Collection: collection,
	}); err != nil {
		t.Fatalf("VolumeEcShardsGenerate: %v", err)
	}
	postGenerateLayout := scanShardLayout(t, dataDirs, collection, volumeID)
	if len(postGenerateLayout[0]) != erasure_coding.TotalShardsCount {
		t.Fatalf("post-generate: expected all %d shards on disk 0, got per-disk layout %v",
			erasure_coding.TotalShardsCount, postGenerateLayout)
	}
	if len(postGenerateLayout[1]) != 0 {
		t.Fatalf("post-generate: disk 1 should be empty, got %v", postGenerateLayout[1])
	}
	// .ecj is created lazily on first EC delete
	for _, side := range []string{".ecx", ".vif"} {
		if !fileExistsIn(dataDirs[0], collection, volumeID, side) {
			t.Fatalf("post-generate: expected %s on disk 0", side)
		}
	}

	// mount + read via EC
	if _, err := grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
		VolumeId:   volumeID,
		Collection: collection,
		ShardIds:   dataShardIds(),
	}); err != nil {
		t.Fatalf("VolumeEcShardsMount data shards: %v", err)
	}
	for _, n := range needles {
		verifyHTTPRead(t, httpClient, clusterHarness.VolumeAdminURL(), n.fid, n.payload, "after-mount")
	}

	// drop the original volume so EC shards are the only source
	if _, err := grpcClient.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{
		VolumeId: volumeID,
	}); err != nil {
		t.Fatalf("VolumeDelete (drop .dat): %v", err)
	}
	if fileExistsIn(dataDirs[0], collection, volumeID, ".dat") {
		t.Fatalf("VolumeDelete left .dat in place on disk 0")
	}
	for _, n := range needles {
		verifyHTTPRead(t, httpClient, clusterHarness.VolumeAdminURL(), n.fid, n.payload, "after-dat-delete")
	}

	// move half the shards onto disk 1, leaving .ecx on disk 0
	clusterHarness.StopVolumeServer()
	const splitAt = 7
	for shard := 0; shard < splitAt; shard++ {
		movedFile(t, dataDirs[0], dataDirs[1], collection, volumeID, erasure_coding.ToExt(shard))
	}
	postMoveLayout := scanShardLayout(t, dataDirs, collection, volumeID)
	if len(postMoveLayout[0]) != erasure_coding.TotalShardsCount-splitAt {
		t.Fatalf("post-move: expected %d shards on disk 0, got %v", erasure_coding.TotalShardsCount-splitAt, postMoveLayout[0])
	}
	if len(postMoveLayout[1]) != splitAt {
		t.Fatalf("post-move: expected %d shards on disk 1, got %v", splitAt, postMoveLayout[1])
	}
	if !fileExistsIn(dataDirs[0], collection, volumeID, ".ecx") {
		t.Fatalf("post-move: .ecx must stay on disk 0 to exercise the cross-disk reconcile path")
	}
	if fileExistsIn(dataDirs[1], collection, volumeID, ".ecx") {
		t.Fatalf("post-move: .ecx leaked onto disk 1; reconcile path would not be exercised")
	}

	clusterHarness.RestartVolumeServer()
	conn2, grpcClient2 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
	defer conn2.Close()

	postReconcileLayout := scanShardLayout(t, dataDirs, collection, volumeID)
	if got, want := totalShardsInLayout(postReconcileLayout), erasure_coding.TotalShardsCount; got != want {
		t.Fatalf("post-reconcile: total shards on disk mismatch: got %d, want %d (layout=%v)", got, want, postReconcileLayout)
	}
	if got, want := len(postReconcileLayout[0]), erasure_coding.TotalShardsCount-splitAt; got != want {
		t.Fatalf("post-reconcile: disk 0 shard count drift: got %d, want %d (layout=%v)", got, want, postReconcileLayout)
	}
	if got, want := len(postReconcileLayout[1]), splitAt; got != want {
		t.Fatalf("post-reconcile: disk 1 shard count drift: got %d, want %d (layout=%v)", got, want, postReconcileLayout)
	}
	// VolumeEcShardsInfo must walk every DiskLocation and report the full
	// shard set — the verification step in ec_task.go gates source-volume
	// deletion on this RPC returning a complete shard inventory.
	infoResp, err := grpcClient2.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{
		VolumeId: volumeID,
	})
	if err != nil {
		t.Fatalf("VolumeEcShardsInfo after redistribute restart: %v", err)
	}
	if got, want := len(infoResp.GetEcShardInfos()), erasure_coding.TotalShardsCount; got != want {
		t.Fatalf("VolumeEcShardsInfo after redistribute restart: got %d shards, want %d (per-disk layout=%v)",
			got, want, postReconcileLayout)
	}
	gotShardIds := make(map[uint32]struct{}, len(infoResp.GetEcShardInfos()))
	for _, info := range infoResp.GetEcShardInfos() {
		gotShardIds[info.GetShardId()] = struct{}{}
	}
	for shardId := uint32(0); shardId < uint32(erasure_coding.TotalShardsCount); shardId++ {
		if _, ok := gotShardIds[shardId]; !ok {
			t.Fatalf("VolumeEcShardsInfo missing shard %d (per-disk layout=%v)", shardId, postReconcileLayout)
		}
	}
	for _, n := range needles {
		verifyHTTPRead(t, httpClient, clusterHarness.VolumeAdminURL(), n.fid, n.payload, "after-cross-disk-reconcile")
	}

	// blob delete on a cross-disk layout
	deletedNeedle := needles[2]
	deleteReq, err := http.NewRequest(http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+deletedNeedle.fid, nil)
	if err != nil {
		t.Fatalf("build delete request: %v", err)
	}
	deleteResp := framework.DoRequest(t, httpClient, deleteReq)
	_ = framework.ReadAllAndClose(t, deleteResp)
	if deleteResp.StatusCode != http.StatusAccepted {
		t.Fatalf("HTTP DELETE %s expected 202, got %d", deletedNeedle.fid, deleteResp.StatusCode)
	}
	readResp := framework.ReadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), deletedNeedle.fid)
	_ = framework.ReadAllAndClose(t, readResp)
	if readResp.StatusCode == http.StatusOK {
		t.Fatalf("HTTP GET on deleted EC needle expected non-200, got %d", readResp.StatusCode)
	}
	for _, n := range needles {
		if n.fid == deletedNeedle.fid {
			continue
		}
		verifyHTTPRead(t, httpClient, clusterHarness.VolumeAdminURL(), n.fid, n.payload, "after-blob-delete")
	}

	// rebuild a shard whose inputs are split across two disks
	clusterHarness.StopVolumeServer()
	const repairTargetShard = 9 // sits on disk 0 after the split
	shardPath := filepath.Join(dataDirs[0], shardFileName(collection, volumeID, repairTargetShard))
	if _, statErr := os.Stat(shardPath); statErr != nil {
		t.Fatalf("repair setup: shard %d not on disk 0 (%s): %v", repairTargetShard, shardPath, statErr)
	}
	if err := os.Remove(shardPath); err != nil {
		t.Fatalf("repair setup: remove shard %d on disk 0: %v", repairTargetShard, err)
	}
	clusterHarness.RestartVolumeServer()
	conn3, grpcClient3 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
	defer conn3.Close()

	rebuildResp, err := grpcClient3.VolumeEcShardsRebuild(ctx, &volume_server_pb.VolumeEcShardsRebuildRequest{
		VolumeId:   volumeID,
		Collection: collection,
	})
	if err != nil {
		t.Fatalf("VolumeEcShardsRebuild after cross-disk shard loss: %v", err)
	}
	if !containsUint32(rebuildResp.GetRebuiltShardIds(), repairTargetShard) {
		t.Fatalf("VolumeEcShardsRebuild expected to rebuild shard %d, got %v",
			repairTargetShard, rebuildResp.GetRebuiltShardIds())
	}
	// The rebuilder picks whichever disk hosts the most shards plus a
	// matching .ecx. After the boot-time mirror runs, every shard-
	// bearing disk owns its own sidecars, so rebuild can legitimately
	// restore the shard on either disk — accept both.
	rebuiltOn := -1
	for i, dir := range dataDirs {
		candidate := filepath.Join(dir, shardFileName(collection, volumeID, repairTargetShard))
		if _, statErr := os.Stat(candidate); statErr == nil {
			rebuiltOn = i
			shardPath = candidate
			break
		}
	}
	if rebuiltOn < 0 {
		t.Fatalf("rebuild did not restore shard %d on any disk (checked %v)", repairTargetShard, dataDirs)
	}

	if _, err := grpcClient3.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
		VolumeId:   volumeID,
		Collection: collection,
		ShardIds:   []uint32{repairTargetShard},
	}); err != nil {
		t.Fatalf("VolumeEcShardsMount rebuilt shard %d: %v", repairTargetShard, err)
	}
	for _, n := range needles {
		if n.fid == deletedNeedle.fid {
			continue
		}
		verifyHTTPRead(t, httpClient, clusterHarness.VolumeAdminURL(), n.fid, n.payload, "after-repair")
	}
}

// TestEcPartialShardsOnSiblingDiskCleanedUpOnRestart seeds a healthy .dat on
// disk 0, plants the on-disk footprint of an interrupted EC encode on disk 1
// (one shard plus .ecx / .ecj / .vif, no .dat), and asserts the startup prune
// wipes disk 1 without touching disk 0.
func TestEcPartialShardsOnSiblingDiskCleanedUpOnRestart(t *testing.T) {
	if testing.Short() {
		t.Skip("skipping integration test in short mode")
	}

	const (
		dataDirCount = 2
		volumeID     = uint32(9501)
		collection   = "ec-9478"
		partialShard = 1
	)

	clusterHarness := framework.StartSingleVolumeClusterWithDataDirs(t, matrix.P1(), dataDirCount)
	dataDirs := clusterHarness.VolumeDataDirs()
	if len(dataDirs) != dataDirCount {
		t.Fatalf("expected %d data dirs, got %d: %v", dataDirCount, len(dataDirs), dataDirs)
	}

	conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
	defer conn.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	framework.AllocateVolume(t, grpcClient, volumeID, collection)

	httpClient := framework.NewHTTPClient()
	needleFid := framework.NewFileID(volumeID, 7777, 0xCAFEBABE)
	needlePayload := bytesOfLen(4096, 0xC7)
	upResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), needleFid, needlePayload)
	_ = framework.ReadAllAndClose(t, upResp)
	if upResp.StatusCode != http.StatusCreated {
		t.Fatalf("seed upload expected 201, got %d", upResp.StatusCode)
	}

	datPath := filepath.Join(dataDirs[0], datFileName(collection, volumeID))
	datInfo, err := os.Stat(datPath)
	if err != nil {
		t.Fatalf("seed setup: .dat must exist on disk 0 (%s): %v", datPath, err)
	}
	// prune's size guard refuses cleanup if the sibling .dat looks truncated
	if datInfo.Size() == 0 {
		t.Fatalf("seed setup: .dat on disk 0 is zero bytes")
	}

	clusterHarness.StopVolumeServer()
	plantPartialEc(t, dataDirs[1], collection, volumeID, partialShard, datInfo.Size())

	preRestartLayout := scanShardLayout(t, dataDirs, collection, volumeID)
	if len(preRestartLayout[0]) != 0 {
		t.Fatalf("seed setup: disk 0 should hold no EC shards before restart, got %v", preRestartLayout[0])
	}
	if len(preRestartLayout[1]) != 1 {
		t.Fatalf("seed setup: disk 1 should hold exactly one planted shard, got %v", preRestartLayout[1])
	}

	clusterHarness.RestartVolumeServer()

	conn2, grpcClient2 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
	defer conn2.Close()

	// the volume admin port answers before the startup prune finishes
	postPruneLayout := waitForLayout(t, dataDirs, collection, volumeID, func(layout map[int][]int) bool {
		return len(layout[1]) == 0
	}, 10*time.Second)

	if len(postPruneLayout[1]) != 0 {
		t.Fatalf("partial EC shards survived restart on disk 1: %v", postPruneLayout[1])
	}
	// .vif is intentionally left behind: with no .ecx / shard to anchor it,
	// the next startup ignores it.
	for _, side := range []string{".ecx", ".ecj"} {
		if fileExistsIn(dataDirs[1], collection, volumeID, side) {
			t.Fatalf("%s survived prune on disk 1", side)
		}
	}
	if !fileExistsIn(dataDirs[0], collection, volumeID, ".dat") {
		t.Fatalf("prune wiped the healthy .dat on disk 0")
	}
	if !fileExistsIn(dataDirs[0], collection, volumeID, ".idx") {
		t.Fatalf("prune wiped the healthy .idx on disk 0")
	}
	if _, err = grpcClient2.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{
		VolumeId: volumeID,
	}); err == nil {
		t.Fatalf("VolumeEcShardsInfo returned success after prune; the partial EC mount should be gone")
	}
	verifyHTTPRead(t, httpClient, clusterHarness.VolumeAdminURL(), needleFid, needlePayload, "after-prune")
}

func bytesOfLen(n int, seed byte) []byte {
	out := make([]byte, n)
	for i := range out {
		out[i] = byte(int(seed) + i*31)
	}
	return out
}

func dataShardIds() []uint32 {
	ids := make([]uint32, erasure_coding.DataShardsCount)
	for i := range ids {
		ids[i] = uint32(i)
	}
	return ids
}

func datFileName(collection string, volumeID uint32) string {
	if collection == "" {
		return fmt.Sprintf("%d.dat", volumeID)
	}
	return fmt.Sprintf("%s_%d.dat", collection, volumeID)
}

func shardFileName(collection string, volumeID uint32, shardID int) string {
	if collection == "" {
		return fmt.Sprintf("%d%s", volumeID, erasure_coding.ToExt(shardID))
	}
	return fmt.Sprintf("%s_%d%s", collection, volumeID, erasure_coding.ToExt(shardID))
}

func sideFileName(collection string, volumeID uint32, ext string) string {
	if collection == "" {
		return fmt.Sprintf("%d%s", volumeID, ext)
	}
	return fmt.Sprintf("%s_%d%s", collection, volumeID, ext)
}

func fileExistsIn(dir, collection string, volumeID uint32, ext string) bool {
	_, err := os.Stat(filepath.Join(dir, sideFileName(collection, volumeID, ext)))
	return err == nil
}

func scanShardLayout(t testing.TB, dataDirs []string, collection string, volumeID uint32) map[int][]int {
	t.Helper()
	out := make(map[int][]int, len(dataDirs))
	for diskIdx, dir := range dataDirs {
		out[diskIdx] = nil
		for shard := 0; shard < erasure_coding.TotalShardsCount; shard++ {
			if _, err := os.Stat(filepath.Join(dir, shardFileName(collection, volumeID, shard))); err == nil {
				out[diskIdx] = append(out[diskIdx], shard)
			}
		}
		sort.Ints(out[diskIdx])
	}
	return out
}

func waitForLayout(t testing.TB, dataDirs []string, collection string, volumeID uint32, pred func(map[int][]int) bool, timeout time.Duration) map[int][]int {
	t.Helper()
	deadline := time.Now().Add(timeout)
	var layout map[int][]int
	for time.Now().Before(deadline) {
		layout = scanShardLayout(t, dataDirs, collection, volumeID)
		if pred(layout) {
			return layout
		}
		time.Sleep(100 * time.Millisecond)
	}
	return layout
}

func movedFile(t testing.TB, fromDir, toDir, collection string, volumeID uint32, ext string) {
	t.Helper()
	name := sideFileName(collection, volumeID, ext)
	src := filepath.Join(fromDir, name)
	dst := filepath.Join(toDir, name)
	if err := os.Rename(src, dst); err != nil {
		t.Fatalf("move %s → %s: %v", src, dst, err)
	}
}

func totalShardsInLayout(layout map[int][]int) int {
	n := 0
	for _, ids := range layout {
		n += len(ids)
	}
	return n
}

func containsUint32(s []uint32, v uint32) bool {
	for _, x := range s {
		if x == v {
			return true
		}
	}
	return false
}

func verifyHTTPRead(t testing.TB, client *http.Client, base, fid string, want []byte, stage string) {
	t.Helper()
	resp := framework.ReadBytes(t, client, base, fid)
	body := framework.ReadAllAndClose(t, resp)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("[%s] GET %s expected 200, got %d", stage, fid, resp.StatusCode)
	}
	if len(body) != len(want) {
		t.Fatalf("[%s] GET %s size mismatch: got %d, want %d", stage, fid, len(body), len(want))
	}
	for i := range want {
		if body[i] != want[i] {
			t.Fatalf("[%s] GET %s byte %d mismatch: got %d, want %d", stage, fid, i, body[i], want[i])
		}
	}
}

// plantPartialEc writes the footprint of an interrupted EC encode (one shard
// sized for datFileSize, plus .ecx / .ecj / .vif, no .dat) into dir.
func plantPartialEc(t testing.TB, dir, collection string, volumeID uint32, shardID int, datFileSize int64) {
	t.Helper()
	shardPath := filepath.Join(dir, shardFileName(collection, volumeID, shardID))
	if f, err := os.Create(shardPath); err == nil {
		expectedShardSize := plantedShardSize(datFileSize)
		if err := f.Truncate(expectedShardSize); err != nil {
			f.Close()
			t.Fatalf("truncate planted shard: %v", err)
		}
		f.Close()
	} else {
		t.Fatalf("create planted shard %s: %v", shardPath, err)
	}
	for _, side := range []struct {
		ext     string
		content []byte
	}{
		{".ecx", []byte("dummy ecx")},
		{".ecj", nil},
	} {
		p := filepath.Join(dir, sideFileName(collection, volumeID, side.ext))
		f, err := os.Create(p)
		if err != nil {
			t.Fatalf("create planted %s: %v", side.ext, err)
		}
		if side.content != nil {
			if _, err := f.Write(side.content); err != nil {
				f.Close()
				t.Fatalf("write planted %s: %v", side.ext, err)
			}
		}
		f.Close()
	}
	// Record the encode-time source size so the prune's byte-exact gate
	// recognizes the sibling .dat as the source, as a real encoded volume does.
	vifPath := filepath.Join(dir, sideFileName(collection, volumeID, ".vif"))
	if err := volume_info.SaveVolumeInfo(vifPath, &volume_server_pb.VolumeInfo{
		Version:     uint32(needle.Version3),
		DatFileSize: datFileSize,
		EcShardConfig: &volume_server_pb.EcShardConfig{
			DataShards:   uint32(erasure_coding.DataShardsCount),
			ParityShards: uint32(erasure_coding.ParityShardsCount),
		},
	}); err != nil {
		t.Fatalf("save planted .vif: %v", err)
	}
}

// mirrors calculateExpectedShardSize in weed/storage/disk_location_ec.go
func plantedShardSize(datFileSize int64) int64 {
	const dataShards = int64(erasure_coding.DataShardsCount)
	largeBatch := int64(erasure_coding.ErasureCodingLargeBlockSize) * dataShards
	numLarge := datFileSize / largeBatch
	shardSize := numLarge * int64(erasure_coding.ErasureCodingLargeBlockSize)
	remaining := datFileSize - numLarge*largeBatch
	if remaining > 0 {
		smallBatch := int64(erasure_coding.ErasureCodingSmallBlockSize) * dataShards
		numSmall := (remaining + smallBatch - 1) / smallBatch
		shardSize += numSmall * int64(erasure_coding.ErasureCodingSmallBlockSize)
	}
	return shardSize
}
