package volume_move

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/seaweedfs/seaweedfs/weed/pb"
	"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
	"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/status"
)

const (
	srcAddr = pb.ServerAddress("src:8080")
	dstAddr = pb.ServerAddress("dst:8080")
)

func volumeStatus(datSize, idxSize, fileCount uint64) *volume_server_pb.ReadVolumeFileStatusResponse {
	return &volume_server_pb.ReadVolumeFileStatusResponse{
		DatFileSize: datSize,
		IdxFileSize: idxSize,
		FileCount:   fileCount,
	}
}

func TestSameServer(t *testing.T) {
	if !SameServer("node:8080", "node:8080.18080") {
		t.Error("one server written with and without an explicit grpc port compared unequal")
	}
	// Test harnesses run several servers on HTTP port 0 with distinct grpc
	// ports; the grpc endpoint is the server's identity.
	if SameServer("127.0.0.1:0.36825", "127.0.0.1:0.38609") {
		t.Error("two servers sharing a degenerate HTTP address compared equal")
	}
	// Malformed addresses (an unvalidated -source/-target flag) must compare
	// without touching the fatal ToGrpcAddress parser.
	if !SameServer("node:abc", "node:abc") {
		t.Error("identical malformed addresses compared unequal")
	}
	if SameServer("node:abc", "node:8080") {
		t.Error("malformed and well-formed addresses compared equal")
	}
	if SameServer("", "node:8080") {
		t.Error("empty and well-formed addresses compared equal")
	}
}

func TestCheckDialable(t *testing.T) {
	// Rejected: the class the fatal parser aborts on, plus dotted forms with
	// a non-numeric component — those canonicalize differently across dial
	// paths ("node:8080.bad" dials as node:bad on one and node:18080 on the
	// other, silently aliasing another server).
	for _, addr := range []pb.ServerAddress{"node:abc", "host.domain.com:x8080", "[::1]:abc",
		"node:8080.bad", "node:bad.8080", "node:.bad", "node:8080."} {
		if err := checkDialable(addr); err == nil {
			t.Errorf("%q accepted although it is unsafe to dial", addr)
		}
	}
	// Accepted: normalized cleanly, or handed to the dialer untouched where
	// a bad address fails as an ordinary error.
	for _, addr := range []pb.ServerAddress{"node:8080", "node:8080.18080", "127.0.0.1:0.36825", "", "node", "node:"} {
		if err := checkDialable(addr); err != nil {
			t.Errorf("%q rejected: %v", addr, err)
		}
	}
}

func TestEmbeddedSourceAddressValidated(t *testing.T) {
	// The target dials the source embedded in copy/tail requests through the
	// same fatal parser; the client must reject a malformed source before
	// issuing any RPC.
	bad := pb.ServerAddress("node:abc")

	ops := map[string]func(m *Mover) error{
		"ReplicateVolume": func(m *Mover) error {
			return m.ReplicateVolume(context.Background(), 7, bad, dstAddr, "", 0, nil)
		},
		"TailVolume": func(m *Mover) error {
			return m.TailVolume(context.Background(), 7, bad, dstAddr, 0, time.Second)
		},
		"CopyAndMountEcShards": func(m *Mover) error {
			return m.CopyAndMountEcShards(context.Background(), 7, "c1", []erasure_coding.ShardId{3}, bad, dstAddr, 0, 0, nil)
		},
		"MoveEcShards": func(m *Mover) error {
			move := ecMove(3)
			move.Source = bad
			return m.MoveEcShards(context.Background(), move, EcMoveOptions{})
		},
	}
	for name, op := range ops {
		cluster := newFakeCluster()
		err := op(cluster.mover())
		if err == nil || !strings.Contains(err.Error(), "invalid volume server address") {
			t.Errorf("%s: expected invalid-address error, got: %v", name, err)
		}
		if len(cluster.callList()) != 0 {
			t.Errorf("%s: RPCs issued with a malformed embedded source: %v", name, cluster.callList())
		}
	}
}

func TestNewMoverRejectsMalformedAddressWithoutDialing(t *testing.T) {
	// The production dial path must fail the move, not the process, when a
	// caller passes an unvalidated malformed address.
	mover := NewMover(grpc.WithTransportCredentials(insecure.NewCredentials()))
	err := mover.LiveMoveVolume(context.Background(), 7, "node:abc", "node:8080", VolumeMoveOptions{})
	if err == nil || !strings.Contains(err.Error(), "invalid volume server address") {
		t.Fatalf("expected invalid-address error, got: %v", err)
	}
}

func assertCalls(t *testing.T, got, want []string) {
	t.Helper()
	if fmt.Sprint(got) != fmt.Sprint(want) {
		t.Fatalf("RPC sequence mismatch:\n got:  %v\n want: %v", got, want)
	}
}

func TestLiveMoveVolumeSequence(t *testing.T) {
	cluster := newFakeCluster()
	cluster.lastAppendAtNs = 12345
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{
		DiskType:        "ssd",
		IoBytePerSecond: 42,
	})
	if err != nil {
		t.Fatalf("LiveMoveVolume: %v", err)
	}

	// The source status read must come after the tail: a write in flight when
	// the source was frozen can land after an earlier read, and verifying
	// against that stale snapshot would miss a missed tail. The first target
	// status read probes whether the target already held the volume, so a
	// failed copy never cleans up someone else's replica.
	assertCalls(t, cluster.callList(), []string{
		"src:8080 VolumeStatus",
		"src:8080 VolumeMarkReadonly",
		"dst:8080 ReadVolumeFileStatus",
		"dst:8080 VolumeCopy",
		"dst:8080 VolumeTailReceiver",
		"src:8080 ReadVolumeFileStatus",
		"dst:8080 ReadVolumeFileStatus",
		"src:8080 VolumeDelete",
	})

	copyReq := cluster.copyReqs[0]
	if copyReq.DiskType != "ssd" || copyReq.IoBytePerSecond != 42 || copyReq.SourceDataNode != string(srcAddr) {
		t.Errorf("copy request not propagated: %+v", copyReq)
	}
	deleteReq := cluster.deleteReqs[0]
	if deleteReq.OnlyEmpty || !deleteReq.KeepRemoteData {
		t.Errorf("source delete must keep remote data and not be only-empty: %+v", deleteReq)
	}
}

func TestLiveMoveVolumeVerifyMismatchKeepsSource(t *testing.T) {
	cluster := newFakeCluster()
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.status[string(dstAddr)] = volumeStatus(900, 100, 10)

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
	if err == nil || !strings.Contains(err.Error(), "behind the source") {
		t.Fatalf("expected target-behind error, got: %v", err)
	}

	calls := cluster.callList()
	for _, call := range calls {
		if call == "src:8080 VolumeDelete" {
			t.Fatalf("source deleted despite verification failure: %v", calls)
		}
	}
	// The incomplete target copy is removed so the restored source stays the
	// only replica, then the source is made writable again.
	if fmt.Sprint(calls[len(calls)-2:]) != fmt.Sprint([]string{"dst:8080 VolumeDelete", "src:8080 VolumeMarkWritable"}) {
		t.Fatalf("expected target cleanup then source restore, got: %v", calls)
	}
}

func TestLiveMoveVolumeAbortKeepsSourceReadonlyWhenTargetCleanupFails(t *testing.T) {
	// Restoring the source while the stale target stays mounted risks
	// divergent replicas; the source stays readonly until a re-run recovers.
	cluster := newFakeCluster()
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.status[string(dstAddr)] = volumeStatus(900, 100, 10)
	cluster.errs["dst:8080 VolumeDelete"] = errors.New("target unreachable")

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
	if err == nil {
		t.Fatal("expected verification failure")
	}
	if !errors.Is(err, ErrSourceKeptReadonly) {
		t.Fatalf("error does not mark the source as deliberately kept readonly: %v", err)
	}

	for _, call := range cluster.callList() {
		if call == "src:8080 VolumeMarkWritable" {
			t.Fatalf("source made writable with the stale target still mounted: %v", cluster.callList())
		}
	}
}

func TestLiveMoveVolumeAlreadyReadonlyStaysReadonly(t *testing.T) {
	cluster := newFakeCluster()
	cluster.readonly[string(srcAddr)] = true
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.errs["dst:8080 VolumeCopy"] = errors.New("copy failed")

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
	if err == nil {
		t.Fatal("expected copy failure")
	}

	// The hard mark is issued even for a readonly-reporting volume: that
	// status also covers low-disk and readonly-but-can-delete states, which
	// still accept needle deletes.
	marked := false
	for _, call := range cluster.callList() {
		if call == "src:8080 VolumeMarkReadonly" {
			marked = true
		}
		// A volume that was readonly before the move (e.g. full) must stay so.
		if call == "src:8080 VolumeMarkWritable" {
			t.Fatal("made an already-readonly volume writable after a failed move")
		}
	}
	if !marked {
		t.Fatalf("readonly-reporting source not hard-frozen: %v", cluster.callList())
	}
}

func TestLiveMoveVolumePreFrozenAbortCleansTarget(t *testing.T) {
	// tiering.run freezes replicas itself and thaws them after a failed move,
	// so even a move that did not do the freezing must remove its target copy
	// on abort — a stale mounted target plus a thawed source is divergence.
	cluster := newFakeCluster()
	cluster.readonly[string(srcAddr)] = true
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.status[string(dstAddr)] = volumeStatus(900, 100, 10)
	cluster.statusFailures[string(dstAddr)] = 1 // no volume on the target before the copy

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
	if err == nil {
		t.Fatal("expected verification failure")
	}

	cleaned := false
	for _, call := range cluster.callList() {
		if call == "dst:8080 VolumeDelete" {
			cleaned = true
		}
		if call == "src:8080 VolumeMarkWritable" {
			t.Fatalf("thawed a source this move did not freeze: %v", cluster.callList())
		}
	}
	if !cleaned {
		t.Fatalf("incomplete target copy not cleaned up: %v", cluster.callList())
	}
}

func TestLiveMoveVolumeCopyErrorCleansMountedTarget(t *testing.T) {
	// The server can finish the copy and mount the target even when the client
	// loses the stream; the abort probes the target and cleans up the copy.
	cluster := newFakeCluster()
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
	cluster.statusFailures[string(dstAddr)] = 1 // no volume on the target before the copy
	cluster.errs["dst:8080 VolumeCopy"] = errors.New("stream lost")

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
	if err == nil {
		t.Fatal("expected copy failure")
	}

	calls := cluster.callList()
	if fmt.Sprint(calls[len(calls)-2:]) != fmt.Sprint([]string{"dst:8080 VolumeDelete", "src:8080 VolumeMarkWritable"}) {
		t.Fatalf("expected target cleanup then source restore, got: %v", calls)
	}
}

func TestLiveMoveVolumeCopyErrorUnknownTargetKeepsSourceReadonly(t *testing.T) {
	// The pre-copy probe failed at the transport level, so whether the target
	// held a replica before the move is unknown; the failed copy may still
	// have mounted a complete copy there (the server can finish after the
	// client loses the stream). Deleting it risks a healthy pre-existing
	// replica, and reopening the source beside it risks two writable replicas
	// taking divergent writes — so the source stays readonly.
	cluster := newFakeCluster()
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
	cluster.statusFailures[string(dstAddr)] = 1
	cluster.statusFailureErr = status.Error(codes.Unavailable, "connection refused")
	cluster.errs["dst:8080 VolumeCopy"] = errors.New("stream lost")

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
	if err == nil || !errors.Is(err, ErrSourceKeptReadonly) {
		t.Fatalf("expected kept-readonly failure, got: %v", err)
	}

	calls := cluster.callList()
	for _, call := range calls {
		if call == "dst:8080 VolumeDelete" {
			t.Fatalf("deleted the target with its prior state unknown: %v", calls)
		}
		if call == "src:8080 VolumeMarkWritable" {
			t.Fatalf("reopened the source beside a possibly-mounted copy: %v", calls)
		}
	}
}

func TestLiveMoveVolumeCopyErrorLeavesPreexistingTarget(t *testing.T) {
	// A target that held the volume before the move is someone else's replica;
	// a failed copy must not delete it.
	cluster := newFakeCluster()
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
	cluster.errs["dst:8080 VolumeCopy"] = errors.New("copy failed")

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
	if err == nil || !errors.Is(err, ErrSourceKeptReadonly) {
		t.Fatalf("expected kept-readonly failure, got: %v", err)
	}

	calls := cluster.callList()
	for _, call := range calls {
		if call == "dst:8080 VolumeDelete" {
			t.Fatalf("deleted a pre-existing target replica after a failed copy: %v", calls)
		}
		if call == "src:8080 VolumeMarkWritable" {
			t.Fatalf("reopened the source with a copy of unprovable origin on the target: %v", calls)
		}
	}
}

func TestLiveMoveVolumeTailErrorAborts(t *testing.T) {
	cluster := newFakeCluster()
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
	cluster.errs["dst:8080 VolumeTailReceiver"] = errors.New("tail failed")

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
	if err == nil || !strings.Contains(err.Error(), "tail volume") {
		t.Fatalf("expected tail error, got: %v", err)
	}

	calls := cluster.callList()
	for _, call := range calls {
		if call == "src:8080 VolumeDelete" {
			t.Fatalf("source deleted despite tail failure: %v", calls)
		}
	}
	if fmt.Sprint(calls[len(calls)-2:]) != fmt.Sprint([]string{"dst:8080 VolumeDelete", "src:8080 VolumeMarkWritable"}) {
		t.Fatalf("expected target cleanup then source restore, got: %v", calls)
	}
}

func TestLiveMoveVolumeTailErrorToleratedForReadonlySource(t *testing.T) {
	// A volume that was already readonly before the move was frozen for the
	// whole copy, so a failed tail has nothing to deliver.
	cluster := newFakeCluster()
	cluster.readonly[string(srcAddr)] = true
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
	cluster.statusFailures[string(dstAddr)] = 1 // no volume on the target before the copy
	cluster.errs["dst:8080 VolumeTailReceiver"] = errors.New("tail failed")

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{IdleTimeout: time.Millisecond})
	if err != nil {
		t.Fatalf("LiveMoveVolume on readonly source with failed tail: %v", err)
	}

	calls := cluster.callList()
	if calls[len(calls)-1] != "src:8080 VolumeDelete" {
		t.Fatalf("move did not complete with source delete: %v", calls)
	}
}

func TestLiveMoveVolumeTailFailureAbortsWhenSourceStillChanging(t *testing.T) {
	// A tolerated tail failure substitutes a drain barrier: the source status
	// must hold still across the idle window, or the move aborts.
	cluster := newFakeCluster()
	cluster.readonly[string(srcAddr)] = true
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
	cluster.statusFailures[string(dstAddr)] = 1 // no volume on the target before the copy
	cluster.statusSeq[string(srcAddr)] = []*volume_server_pb.ReadVolumeFileStatusResponse{
		volumeStatus(1000, 100, 10),
		volumeStatus(1024, 116, 11), // a straggler landed between the two reads
	}
	cluster.errs["dst:8080 VolumeTailReceiver"] = errors.New("tail failed")

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{IdleTimeout: time.Millisecond})
	if err == nil || !strings.Contains(err.Error(), "still changing") {
		t.Fatalf("expected still-changing abort, got: %v", err)
	}

	for _, call := range cluster.callList() {
		if call == "src:8080 VolumeDelete" {
			t.Fatalf("source deleted while still changing: %v", cluster.callList())
		}
	}
}

func TestLiveMoveVolumeSourceDeleteFailureKeepsBothReadonly(t *testing.T) {
	// Past verification the target is complete and may hold new writes, and
	// the failed delete may or may not have reached the source. Deleting the
	// target or reopening the source could fork the volume; keep everything
	// readonly for a re-run.
	cluster := newFakeCluster()
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
	cluster.errs["src:8080 VolumeDelete"] = errors.New("delete timed out")

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
	if err == nil {
		t.Fatal("expected source delete failure")
	}
	if !errors.Is(err, ErrSourceKeptReadonly) {
		t.Fatalf("error does not mark the source as deliberately kept readonly: %v", err)
	}

	for _, call := range cluster.callList() {
		if call == "dst:8080 VolumeDelete" {
			t.Fatalf("deleted the verified target copy after a failed source delete: %v", cluster.callList())
		}
		if call == "src:8080 VolumeMarkWritable" {
			t.Fatalf("restored source writability next to a mounted verified copy: %v", cluster.callList())
		}
	}
}

func TestLiveMoveVolumeRefusesRecopyOverCompleteTarget(t *testing.T) {
	// A readonly source next to a target already holding a copy that is not
	// behind it is the signature of a previous move whose source delete
	// failed; recopying would tear down the possibly-authoritative target.
	cluster := newFakeCluster()
	cluster.readonly[string(srcAddr)] = true
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
	if err == nil || !strings.Contains(err.Error(), "already has a copy") {
		t.Fatalf("expected recopy refusal, got: %v", err)
	}
	if !errors.Is(err, ErrSourceKeptReadonly) {
		t.Fatalf("refusal does not mark the source as deliberately kept readonly: %v", err)
	}

	for _, call := range cluster.callList() {
		if call == "dst:8080 VolumeCopy" || call == "dst:8080 VolumeDelete" {
			t.Fatalf("touched the possibly-authoritative target: %v", cluster.callList())
		}
	}
}

func TestLiveMoveVolumeReadonlySourceUnknownTargetRefused(t *testing.T) {
	// With a readonly source, an unreachable target probe must fail closed:
	// the target could hold the authoritative copy of a previous move.
	cluster := newFakeCluster()
	cluster.readonly[string(srcAddr)] = true
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.statusFailures[string(dstAddr)] = 1
	cluster.statusFailureErr = status.Error(codes.Unavailable, "connection refused")

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
	if err == nil || !errors.Is(err, ErrSourceKeptReadonly) {
		t.Fatalf("expected fail-closed refusal, got: %v", err)
	}

	for _, call := range cluster.callList() {
		if call == "dst:8080 VolumeCopy" {
			t.Fatalf("copied over a target in unknown state: %v", cluster.callList())
		}
	}
}

func TestLiveMoveVolumePreFrozenSourceMissingRustTargetProceeds(t *testing.T) {
	// The Rust volume server answers a missing volume with codes.NotFound
	// (the Go server uses a plain error, code Unknown). Both are definitive
	// absence: with a pre-frozen source (tiering freezes before moving), a
	// NotFound misclassified as "unknown" would refuse the move with
	// ErrSourceKeptReadonly — and tiering callers then deliberately skip
	// thawing, stranding every replica readonly after an ordinary move.
	cluster := newFakeCluster()
	cluster.readonly[string(srcAddr)] = true
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
	cluster.statusFailures[string(dstAddr)] = 1
	cluster.statusFailureErr = status.Error(codes.NotFound, "not found volume id 7")

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
	if err != nil {
		t.Fatalf("LiveMoveVolume to an empty Rust target: %v", err)
	}

	calls := cluster.callList()
	if calls[len(calls)-1] != "src:8080 VolumeDelete" {
		t.Fatalf("move did not complete with source delete: %v", calls)
	}
}

func TestLiveMoveVolumeReadonlySourceExistingTargetRefused(t *testing.T) {
	// No client-side observation can prove an existing copy is a stale
	// remnant rather than the authoritative copy of an unfinished move —
	// compaction shrinks the authoritative copy below the stale source, and
	// any snapshot can be invalidated by a write right after it. Even a
	// target that reads as behind is refused.
	cluster := newFakeCluster()
	cluster.readonly[string(srcAddr)] = true
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.status[string(dstAddr)] = volumeStatus(900, 90, 9)

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
	if err == nil || !errors.Is(err, ErrSourceKeptReadonly) {
		t.Fatalf("expected fail-closed refusal, got: %v", err)
	}

	for _, call := range cluster.callList() {
		if call == "dst:8080 VolumeCopy" || call == "dst:8080 VolumeDelete" {
			t.Fatalf("touched a possibly-authoritative target: %v", cluster.callList())
		}
	}
}

func TestCopyVolumeLeavesReadonlySourceUntouched(t *testing.T) {
	// The copy is non-destructive, so a readonly-reporting source (possibly
	// only transiently, e.g. low disk) is not hard-marked.
	cluster := newFakeCluster()
	cluster.readonly[string(srcAddr)] = true

	_, err := cluster.mover().CopyVolume(context.Background(), 7, srcAddr, dstAddr, "", 0, true, nil)
	if err != nil {
		t.Fatalf("CopyVolume: %v", err)
	}

	for _, call := range cluster.callList() {
		if call == "src:8080 VolumeMarkReadonly" || call == "src:8080 VolumeMarkWritable" {
			t.Fatalf("readonly source touched by a non-destructive copy: %v", cluster.callList())
		}
	}
}

func TestLiveMoveVolumeTargetAheadCommits(t *testing.T) {
	// The target serves writes while the move tails; a target that got ahead
	// holds those acknowledged writes, and the move must commit so they stay
	// on the surviving copy.
	cluster := newFakeCluster()
	cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
	cluster.status[string(dstAddr)] = volumeStatus(1100, 110, 11)

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
	if err != nil {
		t.Fatalf("LiveMoveVolume with target ahead: %v", err)
	}

	calls := cluster.callList()
	if calls[len(calls)-1] != "src:8080 VolumeDelete" {
		t.Fatalf("move did not commit with source delete: %v", calls)
	}
}

func TestLiveMoveVolumeRejectsSameServer(t *testing.T) {
	// The second target is the same server written with an explicit grpc port;
	// the guard must see through the representation difference.
	for _, target := range []pb.ServerAddress{srcAddr, pb.ServerAddress("src:8080.18080")} {
		cluster := newFakeCluster()

		err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, target, VolumeMoveOptions{})
		if err == nil || !strings.Contains(err.Error(), "its own server") {
			t.Fatalf("target %q: expected same-server rejection, got: %v", target, err)
		}
		if len(cluster.callList()) != 0 {
			t.Fatalf("target %q: RPCs issued for a rejected move: %v", target, cluster.callList())
		}
	}
}

func TestLiveMoveVolumeReadonlyMarkFailureRestores(t *testing.T) {
	// The marking RPC can fail after the server applied the mark (e.g. its
	// master notification failed), so a mark error must still restore.
	cluster := newFakeCluster()
	cluster.errs["src:8080 VolumeMarkReadonly"] = errors.New("master notification failed")

	err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
	if err == nil || !strings.Contains(err.Error(), "mark volume") {
		t.Fatalf("expected mark-readonly error, got: %v", err)
	}

	calls := cluster.callList()
	if calls[len(calls)-1] != "src:8080 VolumeMarkWritable" {
		t.Fatalf("source writability not restored after failed readonly mark: %v", calls)
	}
}

func TestReplicateVolumePropagatesThroughput(t *testing.T) {
	cluster := newFakeCluster()

	if err := cluster.mover().ReplicateVolume(context.Background(), 7, srcAddr, dstAddr, "ssd", 55, nil); err != nil {
		t.Fatalf("ReplicateVolume: %v", err)
	}
	copyReq := cluster.copyReqs[0]
	if copyReq.IoBytePerSecond != 55 || copyReq.DiskType != "ssd" {
		t.Errorf("replicate request not propagated: %+v", copyReq)
	}
}

func TestCopyVolumeRestoreWritable(t *testing.T) {
	for _, restoreWritable := range []bool{true, false} {
		cluster := newFakeCluster()
		_, err := cluster.mover().CopyVolume(context.Background(), 7, srcAddr, dstAddr, "", 0, restoreWritable, nil)
		if err != nil {
			t.Fatalf("CopyVolume(restoreWritable=%v): %v", restoreWritable, err)
		}
		restored := false
		for _, call := range cluster.callList() {
			if call == "src:8080 VolumeMarkWritable" {
				restored = true
			}
		}
		if restored != restoreWritable {
			t.Errorf("restoreWritable=%v but writability restored=%v", restoreWritable, restored)
		}
	}
}
