package s3api

import (
	"context"
	"io"
	"math"
	"reflect"
	"sync/atomic"
	"testing"
	"time"

	"github.com/seaweedfs/seaweedfs/weed/pb"
	"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
	"github.com/seaweedfs/seaweedfs/weed/wdclient"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/status"
)

// fakeListFiler serves ListEntries from names; the first failCalls calls die
// with Unavailable after streaming sendBeforeFail entries.
type fakeListFiler struct {
	filer_pb.UnimplementedSeaweedFilerServer
	names          []string
	failCalls      int32
	sendBeforeFail int
	calls          int32
}

func (f *fakeListFiler) ListEntries(req *filer_pb.ListEntriesRequest, stream filer_pb.SeaweedFiler_ListEntriesServer) error {
	names := f.names
	failing := atomic.AddInt32(&f.calls, 1) <= f.failCalls
	if failing {
		names = names[:f.sendBeforeFail]
	}
	for _, name := range names {
		if err := stream.Send(&filer_pb.ListEntriesResponse{Entry: &filer_pb.Entry{Name: name}}); err != nil {
			return err
		}
	}
	if failing {
		return status.Error(codes.Unavailable, "filer restarting")
	}
	return nil
}

func newFailoverTestServer(t *testing.T, filers ...pb.ServerAddress) *S3ApiServer {
	t.Helper()
	dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
	return &S3ApiServer{
		option:      &S3ApiServerOption{Filers: filers, GrpcDialOption: dialOption},
		filerClient: wdclient.NewFilerClient(filers, dialOption, ""),
	}
}

// accumulateListing is the callback shape the failover contract has to protect:
// entries collect into a variable that survives a replay of the callback.
func accumulateListing(got *[]string) func(filer_pb.SeaweedFilerClient) error {
	return func(client filer_pb.SeaweedFilerClient) error {
		stream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{Directory: "/d"})
		if err != nil {
			return err
		}
		for {
			resp, err := stream.Recv()
			if err == io.EOF {
				return nil
			}
			if err != nil {
				return err
			}
			*got = append(*got, resp.Entry.Name)
		}
	}
}

// A filer that dies mid-stream must surface the error, not fail over: the
// callback has already consumed part of the response, and replaying it against
// the next filer would append a second copy of what it accumulated.
func TestFailoverStopsAfterPartialResponse(t *testing.T) {
	names := []string{"e1", "e2", "e3", "e4"}
	flaky := &fakeListFiler{names: names, failCalls: math.MaxInt32, sendBeforeFail: 2}
	healthy := &fakeListFiler{names: names}
	s3a := newFailoverTestServer(t, startFakeFiler(t, flaky), startFakeFiler(t, healthy))

	var got []string
	err := s3a.WithFilerClient(true, accumulateListing(&got))
	if err == nil {
		t.Fatalf("want the mid-stream failure surfaced, got success with %v", got)
	}
	if calls := atomic.LoadInt32(&healthy.calls); calls != 0 {
		t.Fatalf("callback was replayed on the second filer %d time(s)", calls)
	}
}

// A filer that fails before delivering anything is still failed over, so the
// partial-response guard does not cost the healthy-peer retry that failover exists for.
func TestFailoverBeforeFirstResponse(t *testing.T) {
	names := []string{"e1", "e2", "e3", "e4"}
	flaky := &fakeListFiler{names: names, failCalls: math.MaxInt32, sendBeforeFail: 0}
	healthy := &fakeListFiler{names: names}
	s3a := newFailoverTestServer(t, startFakeFiler(t, flaky), startFakeFiler(t, healthy))

	var got []string
	err := s3a.WithFilerClient(true, accumulateListing(&got))
	if err != nil {
		t.Fatalf("want failover success, got %v", err)
	}
	if !reflect.DeepEqual(got, names) {
		t.Fatalf("entries = %v, want %v", got, names)
	}
}

// End to end through the real listing path: a mid-stream failure surfaces to
// listWithRetry, whose replay starts from a fresh accumulator, so the caller
// sees each entry exactly once instead of a silently duplicated prefix.
func TestListAfterMidStreamFailureHasNoDuplicates(t *testing.T) {
	names := []string{"e1", "e2", "e3", "e4"}
	flaky := &fakeListFiler{names: names, failCalls: 1, sendBeforeFail: 2}
	healthy := &fakeListFiler{names: names}
	s3a := newFailoverTestServer(t, startFakeFiler(t, flaky), startFakeFiler(t, healthy))

	entries, isLast, err := s3a.list("/d", "", "", false, 10)
	if err != nil {
		t.Fatalf("list: %v", err)
	}
	var got []string
	for _, entry := range entries {
		got = append(got, entry.Name)
	}
	if !reflect.DeepEqual(got, names) {
		t.Fatalf("entries = %v, want %v", got, names)
	}
	if !isLast {
		t.Fatal("want isLast")
	}
}

// hangingCollectionFiler answers CollectionList only when the caller gives up,
// standing in for a filer waiting on a master that has stopped answering.
type hangingCollectionFiler struct {
	filer_pb.UnimplementedSeaweedFilerServer
	calls atomic.Int32
}

func (f *hangingCollectionFiler) CollectionList(ctx context.Context, _ *filer_pb.CollectionListRequest) (*filer_pb.CollectionListResponse, error) {
	f.calls.Add(1)
	<-ctx.Done()
	return nil, ctx.Err()
}

// The caller's own budget expiring says nothing about the filer that was
// answering it. Counted as a filer failure it would, after the three that open
// the circuit, mark every filer in the walk unhealthy -- and LookupVolumeIds
// skips unhealthy filers, so unrelated object reads would start failing over a
// slow master they never touched.
func TestFailoverDoesNotBlameFilersForTheCallersExpiredBudget(t *testing.T) {
	first := &hangingCollectionFiler{}
	second := &hangingCollectionFiler{}
	firstAddr := startFakeFiler(t, first)
	secondAddr := startFakeFiler(t, second)
	s3a := newFailoverTestServer(t, firstAddr, secondAddr)

	// More attempts than the three failures it takes to open the circuit.
	for attempt := 0; attempt < 5; attempt++ {
		ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
		err := s3a.withFilerClient(ctx, false, func(client filer_pb.SeaweedFilerClient) error {
			_, listErr := client.CollectionList(ctx, &filer_pb.CollectionListRequest{})
			return listErr
		})
		cancel()
		if err == nil {
			t.Fatalf("attempt %d: want the expired budget surfaced as an error", attempt)
		}
	}

	for _, addr := range []pb.ServerAddress{firstAddr, secondAddr} {
		if s3a.filerClient.ShouldSkipUnhealthyFiler(addr) {
			t.Errorf("filer %s was flagged unhealthy for the caller's own timeout; unrelated reads would now skip it", addr)
		}
	}

	// And the walk stops rather than spending an already-spent budget on the next
	// filer, which cannot answer any faster.
	if n := second.calls.Load(); n != 0 {
		t.Errorf("the second filer was tried %d times with no budget left to answer in", n)
	}
}
