package mxgateway

import (
	"context"
	"errors"
	"net"
	"sync"
	"testing"
	"time"

	pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/grpc/test/bufconn"
	"google.golang.org/protobuf/types/known/timestamppb"
)

func TestGalaxyTestConnectionAttachesAuthAndReturnsOk(t *testing.T) {
	fake := &fakeGalaxyServer{
		testReply: &pb.TestConnectionReply{Ok: true},
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	ok, err := client.TestConnection(context.Background())
	if err != nil {
		t.Fatalf("TestConnection() error = %v", err)
	}
	if !ok {
		t.Fatalf("TestConnection() ok = false, want true")
	}
	if got := fake.testAuth; got != "Bearer test-api-key" {
		t.Fatalf("authorization metadata = %q, want %q", got, "Bearer test-api-key")
	}
}

func TestGalaxyGetLastDeployTimeReturnsAbsentForPresentFalse(t *testing.T) {
	fake := &fakeGalaxyServer{
		deployReply: &pb.GetLastDeployTimeReply{Present: false},
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	got, present, err := client.GetLastDeployTime(context.Background())
	if err != nil {
		t.Fatalf("GetLastDeployTime() error = %v", err)
	}
	if present {
		t.Fatalf("present = true, want false")
	}
	if !got.IsZero() {
		t.Fatalf("time = %v, want zero", got)
	}
}

func TestGalaxyGetLastDeployTimeReturnsTimestampWhenPresent(t *testing.T) {
	want := time.Date(2026, 4, 28, 12, 34, 56, 0, time.UTC)
	fake := &fakeGalaxyServer{
		deployReply: &pb.GetLastDeployTimeReply{
			Present:          true,
			TimeOfLastDeploy: timestamppb.New(want),
		},
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	got, present, err := client.GetLastDeployTime(context.Background())
	if err != nil {
		t.Fatalf("GetLastDeployTime() error = %v", err)
	}
	if !present {
		t.Fatalf("present = false, want true")
	}
	if !got.Equal(want) {
		t.Fatalf("time = %v, want %v", got, want)
	}
}

func TestGalaxyGetLastDeployTimeReturnsAbsentWhenTimestampNil(t *testing.T) {
	fake := &fakeGalaxyServer{
		deployReply: &pb.GetLastDeployTimeReply{Present: true, TimeOfLastDeploy: nil},
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	got, present, err := client.GetLastDeployTime(context.Background())
	if err != nil {
		t.Fatalf("GetLastDeployTime() error = %v", err)
	}
	if present {
		t.Fatalf("present = true, want false (nil timestamp)")
	}
	if !got.IsZero() {
		t.Fatalf("time = %v, want zero", got)
	}
}

func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) {
	fake := &fakeGalaxyServer{
		discoverReply: &pb.DiscoverHierarchyReply{
			Objects: []*pb.GalaxyObject{
				{
					GobjectId:     1,
					TagName:       "TestMachine_001",
					ContainedName: "TestMachine_001",
					BrowseName:    "TestMachine_001",
					IsArea:        false,
					CategoryId:    7,
					TemplateChain: []string{"$Object", "$AppObject"},
					Attributes: []*pb.GalaxyAttribute{
						{
							AttributeName:    "DownloadPath",
							FullTagReference: "TestMachine_001.DownloadPath",
							MxDataType:       8,
							DataTypeName:     "String",
						},
					},
				},
				{
					GobjectId:       2,
					TagName:         "TestMachine_002",
					ContainedName:   "TestMachine_002",
					ParentGobjectId: 1,
				},
			},
		},
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	objects, err := client.DiscoverHierarchy(context.Background())
	if err != nil {
		t.Fatalf("DiscoverHierarchy() error = %v", err)
	}
	if len(objects) != 2 {
		t.Fatalf("len(objects) = %d, want 2", len(objects))
	}
	if objects[0].GetTagName() != "TestMachine_001" {
		t.Fatalf("objects[0].TagName = %q", objects[0].GetTagName())
	}
	if len(objects[0].GetAttributes()) != 1 {
		t.Fatalf("len(attributes) = %d, want 1", len(objects[0].GetAttributes()))
	}
	if objects[0].GetAttributes()[0].GetFullTagReference() != "TestMachine_001.DownloadPath" {
		t.Fatalf("FullTagReference = %q", objects[0].GetAttributes()[0].GetFullTagReference())
	}
}

func TestGalaxyDiscoverHierarchyPaginatesAcrossMultiplePages(t *testing.T) {
	page1 := &pb.DiscoverHierarchyReply{
		Objects: []*pb.GalaxyObject{
			{GobjectId: 1, TagName: "A"},
			{GobjectId: 2, TagName: "B"},
		},
		NextPageToken:    "page-2",
		TotalObjectCount: 3,
	}
	page2 := &pb.DiscoverHierarchyReply{
		Objects: []*pb.GalaxyObject{
			{GobjectId: 3, TagName: "C"},
		},
		TotalObjectCount: 3,
	}
	fake := &fakeGalaxyServer{
		discoverHierarchyReplies: []*pb.DiscoverHierarchyReply{page1, page2},
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	objs, err := client.DiscoverHierarchy(context.Background())
	if err != nil {
		t.Fatalf("DiscoverHierarchy: %v", err)
	}
	if got, want := len(objs), 3; got != want {
		t.Fatalf("len(objs) = %d, want %d", got, want)
	}
	if len(fake.discoverHierarchyCalls) != 2 {
		t.Fatalf("expected 2 RPC calls, got %d", len(fake.discoverHierarchyCalls))
	}
	if fake.discoverHierarchyCalls[0].GetPageSize() != discoverHierarchyPageSize {
		t.Fatalf("first call PageSize = %d, want %d",
			fake.discoverHierarchyCalls[0].GetPageSize(), discoverHierarchyPageSize)
	}
	if fake.discoverHierarchyCalls[1].GetPageToken() != "page-2" {
		t.Fatalf("second call page token = %q, want %q",
			fake.discoverHierarchyCalls[1].GetPageToken(), "page-2")
	}
}

func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
	fake := &fakeGalaxyServer{failTest: true}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	_, err := client.TestConnection(context.Background())
	if err == nil {
		t.Fatal("TestConnection() error = nil, want error")
	}
	var gwErr *GatewayError
	if !errors.As(err, &gwErr) {
		t.Fatalf("error %T does not support errors.As(*GatewayError)", err)
	}
	if gwErr.Op != "galaxy test connection" {
		t.Fatalf("Op = %q, want %q", gwErr.Op, "galaxy test connection")
	}
}

func TestGalaxyWatchDeployEventsReceivesEventsInOrder(t *testing.T) {
	bootstrap := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
	deploy1 := time.Date(2026, 4, 28, 10, 5, 0, 0, time.UTC)
	deploy2 := time.Date(2026, 4, 28, 10, 6, 0, 0, time.UTC)
	fake := &fakeGalaxyServer{
		watchEvents: []*pb.DeployEvent{
			{
				Sequence:                1,
				ObservedAt:              timestamppb.New(bootstrap),
				TimeOfLastDeploy:        timestamppb.New(deploy1),
				TimeOfLastDeployPresent: true,
				ObjectCount:             10,
				AttributeCount:          42,
			},
			{
				Sequence:                2,
				ObservedAt:              timestamppb.New(deploy2),
				TimeOfLastDeploy:        timestamppb.New(deploy2),
				TimeOfLastDeployPresent: true,
				ObjectCount:             11,
				AttributeCount:          44,
			},
		},
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

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

	events, errs, err := client.WatchDeployEvents(ctx, nil)
	if err != nil {
		t.Fatalf("WatchDeployEvents() error = %v", err)
	}

	got := make([]*DeployEvent, 0, 2)
loop:
	for {
		select {
		case ev, ok := <-events:
			if !ok {
				break loop
			}
			got = append(got, ev)
		case errVal := <-errs:
			if errVal != nil {
				t.Fatalf("error channel: %v", errVal)
			}
		case <-ctx.Done():
			t.Fatalf("timeout waiting for events; got %d", len(got))
		}
	}

	if len(got) != 2 {
		t.Fatalf("len(events) = %d, want 2", len(got))
	}
	if got[0].GetSequence() != 1 || got[1].GetSequence() != 2 {
		t.Fatalf("sequences = [%d,%d], want [1,2]", got[0].GetSequence(), got[1].GetSequence())
	}
	if !got[0].GetTimeOfLastDeployPresent() {
		t.Fatalf("event[0] TimeOfLastDeployPresent = false, want true")
	}
	if got[0].GetObjectCount() != 10 || got[0].GetAttributeCount() != 42 {
		t.Fatalf("event[0] counts = (%d,%d), want (10,42)", got[0].GetObjectCount(), got[0].GetAttributeCount())
	}
	if !got[0].GetTimeOfLastDeploy().AsTime().Equal(deploy1) {
		t.Fatalf("event[0] TimeOfLastDeploy = %v, want %v", got[0].GetTimeOfLastDeploy().AsTime(), deploy1)
	}
}

func TestGalaxyWatchDeployEventsForwardsLastSeenDeployTime(t *testing.T) {
	fake := &fakeGalaxyServer{
		watchEvents: []*pb.DeployEvent{
			{Sequence: 7},
		},
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

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

	lastSeen := time.Date(2026, 4, 28, 9, 0, 0, 0, time.UTC)
	events, errs, err := client.WatchDeployEvents(ctx, &lastSeen)
	if err != nil {
		t.Fatalf("WatchDeployEvents() error = %v", err)
	}

	// Drain everything.
loop:
	for {
		select {
		case _, ok := <-events:
			if !ok {
				break loop
			}
		case errVal := <-errs:
			if errVal != nil {
				t.Fatalf("error channel: %v", errVal)
			}
		case <-ctx.Done():
			t.Fatalf("timeout draining events")
		}
	}

	if fake.watchRequest == nil {
		t.Fatalf("server did not receive a request")
	}
	gotTs := fake.watchRequest.GetLastSeenDeployTime()
	if gotTs == nil {
		t.Fatalf("LastSeenDeployTime = nil, want %v", lastSeen)
	}
	if !gotTs.AsTime().Equal(lastSeen) {
		t.Fatalf("LastSeenDeployTime = %v, want %v", gotTs.AsTime(), lastSeen)
	}
}

func TestGalaxyWatchDeployEventsCancelTearsDownStream(t *testing.T) {
	fake := &fakeGalaxyServer{
		watchEvents: []*pb.DeployEvent{
			{Sequence: 1},
		},
		watchHoldOpen: true,
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	streamCtx, cancelStream := context.WithCancel(context.Background())

	events, errs, err := client.WatchDeployEvents(streamCtx, nil)
	if err != nil {
		t.Fatalf("WatchDeployEvents() error = %v", err)
	}

	// Wait for the bootstrap event to arrive.
	select {
	case ev, ok := <-events:
		if !ok {
			t.Fatalf("events channel closed before delivering bootstrap")
		}
		if ev.GetSequence() != 1 {
			t.Fatalf("got seq=%d, want 1", ev.GetSequence())
		}
	case <-time.After(2 * time.Second):
		t.Fatalf("timeout waiting for bootstrap event")
	}

	// Cancel the stream; both channels must close cleanly without delivering an error.
	cancelStream()

	deadline := time.After(2 * time.Second)
	for events != nil || errs != nil {
		select {
		case _, ok := <-events:
			if !ok {
				events = nil
			}
		case errVal, ok := <-errs:
			if !ok {
				errs = nil
				continue
			}
			if errVal != nil {
				t.Fatalf("error after cancel: %v", errVal)
			}
		case <-deadline:
			t.Fatalf("channels did not close after cancel; events nil=%v errs nil=%v", events == nil, errs == nil)
		}
	}
}

func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient, func()) {
	t.Helper()

	listener := bufconn.Listen(bufSize)
	server := grpc.NewServer()
	pb.RegisterGalaxyRepositoryServer(server, fake)
	go func() {
		if err := server.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
			t.Errorf("bufconn server failed: %v", err)
		}
	}()

	dialer := func(ctx context.Context, _ string) (net.Conn, error) {
		return listener.DialContext(ctx)
	}
	client, err := DialGalaxy(context.Background(), Options{
		Endpoint:  "bufnet",
		APIKey:    "test-api-key",
		Plaintext: true,
		DialOptions: []grpc.DialOption{
			grpc.WithContextDialer(dialer),
		},
	})
	if err != nil {
		t.Fatalf("DialGalaxy() error = %v", err)
	}

	return client, func() {
		client.Close()
		server.Stop()
		listener.Close()
	}
}

type fakeGalaxyServer struct {
	pb.UnimplementedGalaxyRepositoryServer

	testReply                *pb.TestConnectionReply
	testAuth                 string
	failTest                 bool
	deployReply              *pb.GetLastDeployTimeReply
	discoverReply            *pb.DiscoverHierarchyReply
	discoverHierarchyCalls   []*pb.DiscoverHierarchyRequest
	discoverHierarchyReplies []*pb.DiscoverHierarchyReply
	watchEvents              []*pb.DeployEvent
	watchRequest             *pb.WatchDeployEventsRequest
	watchSendInterval        time.Duration
	watchHoldOpen            bool
	browseChildrenCalls      []*pb.BrowseChildrenRequest
	browseChildrenReplies    []*pb.BrowseChildrenReply
	browseChildrenError      error
}

func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
	s.testAuth = authorizationFromContext(ctx)
	if s.failTest {
		return nil, errors.New("simulated failure")
	}
	if s.testReply != nil {
		return s.testReply, nil
	}
	return &pb.TestConnectionReply{Ok: true}, nil
}

func (s *fakeGalaxyServer) GetLastDeployTime(ctx context.Context, req *pb.GetLastDeployTimeRequest) (*pb.GetLastDeployTimeReply, error) {
	if s.deployReply != nil {
		return s.deployReply, nil
	}
	return &pb.GetLastDeployTimeReply{Present: false}, nil
}

func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
	s.discoverHierarchyCalls = append(s.discoverHierarchyCalls, req)
	if len(s.discoverHierarchyReplies) > 0 {
		reply := s.discoverHierarchyReplies[0]
		s.discoverHierarchyReplies = s.discoverHierarchyReplies[1:]
		return reply, nil
	}
	if s.discoverReply != nil {
		return s.discoverReply, nil
	}
	return &pb.DiscoverHierarchyReply{}, nil
}

func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, stream grpc.ServerStreamingServer[pb.DeployEvent]) error {
	s.watchRequest = req
	for _, event := range s.watchEvents {
		if err := stream.Send(event); err != nil {
			return err
		}
		if s.watchSendInterval > 0 {
			select {
			case <-time.After(s.watchSendInterval):
			case <-stream.Context().Done():
				return stream.Context().Err()
			}
		}
	}
	if s.watchHoldOpen {
		<-stream.Context().Done()
	}
	return nil
}

func (s *fakeGalaxyServer) BrowseChildren(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
	s.browseChildrenCalls = append(s.browseChildrenCalls, req)
	if s.browseChildrenError != nil {
		err := s.browseChildrenError
		s.browseChildrenError = nil
		return nil, err
	}
	if len(s.browseChildrenReplies) == 0 {
		return &pb.BrowseChildrenReply{}, nil
	}
	reply := s.browseChildrenReplies[0]
	s.browseChildrenReplies = s.browseChildrenReplies[1:]
	return reply, nil
}

func obj(id int32, tag string, isArea bool) *pb.GalaxyObject {
	return &pb.GalaxyObject{
		GobjectId:  id,
		TagName:    tag,
		BrowseName: tag,
		IsArea:     isArea,
	}
}

func buildBrowseReply(children []*pb.GalaxyObject, hasChildren []bool, seq uint64) *pb.BrowseChildrenReply {
	return &pb.BrowseChildrenReply{
		TotalChildCount:  int32(len(children)),
		CacheSequence:    seq,
		Children:         children,
		ChildHasChildren: hasChildren,
	}
}

func TestGalaxyBrowseNoParentReturnsRoots(t *testing.T) {
	fake := &fakeGalaxyServer{
		browseChildrenReplies: []*pb.BrowseChildrenReply{
			buildBrowseReply(
				[]*pb.GalaxyObject{obj(1, "Plant", true), obj(99, "Other", false)},
				[]bool{true, false},
				7,
			),
		},
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	roots, err := client.Browse(context.Background(), nil)
	if err != nil {
		t.Fatalf("Browse: %v", err)
	}
	if got, want := len(roots), 2; got != want {
		t.Fatalf("len(roots) = %d, want %d", got, want)
	}
	if roots[0].Object().GetTagName() != "Plant" {
		t.Fatalf("roots[0].TagName = %q", roots[0].Object().GetTagName())
	}
	if !roots[0].HasChildrenHint() {
		t.Fatal("roots[0].HasChildrenHint = false, want true")
	}
	if roots[0].IsExpanded() {
		t.Fatal("roots[0].IsExpanded = true, want false")
	}
	if roots[1].HasChildrenHint() {
		t.Fatal("roots[1].HasChildrenHint = true, want false")
	}
	if len(fake.browseChildrenCalls) != 1 {
		t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
	}
	if fake.browseChildrenCalls[0].GetParent() != nil {
		t.Fatalf("root browse should not set Parent oneof, got %T", fake.browseChildrenCalls[0].GetParent())
	}
}

func TestGalaxyBrowseExpandPopulatesChildrenAndMarksExpanded(t *testing.T) {
	fake := &fakeGalaxyServer{
		browseChildrenReplies: []*pb.BrowseChildrenReply{
			buildBrowseReply(
				[]*pb.GalaxyObject{obj(1, "Plant", true)},
				[]bool{true},
				1,
			),
			buildBrowseReply(
				[]*pb.GalaxyObject{obj(10, "Area1", true), obj(11, "Tank1", false)},
				[]bool{true, false},
				1,
			),
		},
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	roots, err := client.Browse(context.Background(), nil)
	if err != nil {
		t.Fatalf("Browse: %v", err)
	}
	if len(roots) != 1 {
		t.Fatalf("len(roots) = %d, want 1", len(roots))
	}
	plant := roots[0]
	if plant.IsExpanded() {
		t.Fatal("plant.IsExpanded = true before Expand, want false")
	}
	if err := plant.Expand(context.Background()); err != nil {
		t.Fatalf("Expand: %v", err)
	}
	if !plant.IsExpanded() {
		t.Fatal("plant.IsExpanded = false after Expand, want true")
	}
	children := plant.Children()
	if len(children) != 2 {
		t.Fatalf("len(children) = %d, want 2", len(children))
	}
	if children[0].Object().GetTagName() != "Area1" {
		t.Fatalf("children[0].TagName = %q, want Area1", children[0].Object().GetTagName())
	}
	if !children[0].HasChildrenHint() {
		t.Fatal("children[0].HasChildrenHint = false, want true")
	}
	if children[1].HasChildrenHint() {
		t.Fatal("children[1].HasChildrenHint = true, want false")
	}
	if len(fake.browseChildrenCalls) != 2 {
		t.Fatalf("BrowseChildren calls = %d, want 2", len(fake.browseChildrenCalls))
	}
	parent := fake.browseChildrenCalls[1].GetParent()
	parentGobj, ok := parent.(*pb.BrowseChildrenRequest_ParentGobjectId)
	if !ok {
		t.Fatalf("Parent oneof = %T, want *BrowseChildrenRequest_ParentGobjectId", parent)
	}
	if parentGobj.ParentGobjectId != 1 {
		t.Fatalf("ParentGobjectId = %d, want 1", parentGobj.ParentGobjectId)
	}
}

func TestGalaxyBrowseExpandIdempotentNoSecondRpc(t *testing.T) {
	fake := &fakeGalaxyServer{
		browseChildrenReplies: []*pb.BrowseChildrenReply{
			buildBrowseReply(
				[]*pb.GalaxyObject{obj(1, "Plant", true)},
				[]bool{true},
				1,
			),
			buildBrowseReply(
				[]*pb.GalaxyObject{obj(10, "Area1", true)},
				[]bool{false},
				1,
			),
		},
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	roots, err := client.Browse(context.Background(), nil)
	if err != nil {
		t.Fatalf("Browse: %v", err)
	}
	plant := roots[0]
	if err := plant.Expand(context.Background()); err != nil {
		t.Fatalf("Expand #1: %v", err)
	}
	callsAfterFirst := len(fake.browseChildrenCalls)
	if callsAfterFirst != 2 {
		t.Fatalf("BrowseChildren calls after first Expand = %d, want 2", callsAfterFirst)
	}
	if err := plant.Expand(context.Background()); err != nil {
		t.Fatalf("Expand #2: %v", err)
	}
	if got := len(fake.browseChildrenCalls); got != callsAfterFirst {
		t.Fatalf("BrowseChildren calls after second Expand = %d, want %d (no extra RPC)", got, callsAfterFirst)
	}
}

func TestGalaxyBrowseExpandUnknownParentReturnsNotFoundError(t *testing.T) {
	fake := &fakeGalaxyServer{
		browseChildrenReplies: []*pb.BrowseChildrenReply{
			buildBrowseReply(
				[]*pb.GalaxyObject{obj(1, "Plant", true)},
				[]bool{true},
				1,
			),
		},
		browseChildrenError: status.Error(codes.NotFound, "parent not found"),
	}
	// The first Browse() consumes the first reply; the next call (Expand) will
	// then hit browseChildrenError. We need the error to fire only on the second
	// call, so seed the reply first and let the call sequence consume them in
	// order. Because BrowseChildren in the fake consumes browseChildrenError
	// before falling through to replies, swap the strategy: keep the root reply
	// but have BrowseChildren return the error on the second call. We do this by
	// emptying the reply list after the first Browse.
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	// First call returns the error (because browseChildrenError takes precedence).
	// To avoid that, clear it for the root call by performing a manual setup: we
	// pre-stage replies first, then set the error after the first call. Easiest:
	// pre-Browse() with error=nil, then set error before Expand.
	fake.browseChildrenError = nil
	roots, err := client.Browse(context.Background(), nil)
	if err != nil {
		t.Fatalf("Browse: %v", err)
	}
	if len(roots) != 1 {
		t.Fatalf("len(roots) = %d, want 1", len(roots))
	}
	fake.browseChildrenError = status.Error(codes.NotFound, "parent not found")

	err = roots[0].Expand(context.Background())
	if err == nil {
		t.Fatal("Expand: error = nil, want NotFound")
	}
	if status.Code(err) != codes.NotFound {
		t.Fatalf("status.Code = %s, want NotFound", status.Code(err))
	}
	if roots[0].IsExpanded() {
		t.Fatal("roots[0].IsExpanded = true after failed Expand, want false")
	}
}

func TestGalaxyBrowseExpandMultiPageGathersAllPages(t *testing.T) {
	firstPage := buildBrowseReply(
		[]*pb.GalaxyObject{obj(1, "Plant", true)},
		[]bool{true},
		7,
	)

	pageA := buildBrowseReply(
		[]*pb.GalaxyObject{obj(10, "Child1", false), obj(11, "Child2", false)},
		[]bool{false, false},
		7,
	)
	pageA.NextPageToken = "7:abc:2"
	pageB := buildBrowseReply(
		[]*pb.GalaxyObject{obj(12, "Child3", false)},
		[]bool{false},
		7,
	)

	fake := &fakeGalaxyServer{
		browseChildrenReplies: []*pb.BrowseChildrenReply{firstPage, pageA, pageB},
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	roots, err := client.Browse(context.Background(), nil)
	if err != nil {
		t.Fatalf("Browse: %v", err)
	}
	if err := roots[0].Expand(context.Background()); err != nil {
		t.Fatalf("Expand: %v", err)
	}
	children := roots[0].Children()
	if len(children) != 3 {
		t.Fatalf("len(children) = %d, want 3", len(children))
	}
	if len(fake.browseChildrenCalls) != 3 {
		t.Fatalf("BrowseChildren calls = %d, want 3", len(fake.browseChildrenCalls))
	}
	if got := fake.browseChildrenCalls[2].GetPageToken(); got != "7:abc:2" {
		t.Fatalf("third call PageToken = %q, want %q", got, "7:abc:2")
	}
}

func TestGalaxyBrowseWithFilterForwardsToRequest(t *testing.T) {
	fake := &fakeGalaxyServer{
		browseChildrenReplies: []*pb.BrowseChildrenReply{
			buildBrowseReply(nil, nil, 1),
		},
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	include := true
	opts := &BrowseChildrenOptions{
		CategoryIds:           []int32{7, 9},
		TemplateChainContains: []string{"$AppObject"},
		TagNameGlob:           "Tank*",
		IncludeAttributes:     &include,
		AlarmBearingOnly:      true,
		HistorizedOnly:        true,
	}
	if _, err := client.Browse(context.Background(), opts); err != nil {
		t.Fatalf("Browse: %v", err)
	}
	if len(fake.browseChildrenCalls) != 1 {
		t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
	}
	got := fake.browseChildrenCalls[0]
	if want := []int32{7, 9}; len(got.GetCategoryIds()) != 2 || got.GetCategoryIds()[0] != want[0] || got.GetCategoryIds()[1] != want[1] {
		t.Fatalf("CategoryIds = %v, want %v", got.GetCategoryIds(), want)
	}
	if want := []string{"$AppObject"}; len(got.GetTemplateChainContains()) != 1 || got.GetTemplateChainContains()[0] != want[0] {
		t.Fatalf("TemplateChainContains = %v, want %v", got.GetTemplateChainContains(), want)
	}
	if got.GetTagNameGlob() != "Tank*" {
		t.Fatalf("TagNameGlob = %q, want %q", got.GetTagNameGlob(), "Tank*")
	}
	if !got.GetIncludeAttributes() {
		t.Fatal("IncludeAttributes = false, want true")
	}
	if !got.GetAlarmBearingOnly() {
		t.Fatal("AlarmBearingOnly = false, want true")
	}
	if !got.GetHistorizedOnly() {
		t.Fatal("HistorizedOnly = false, want true")
	}
}

func TestGalaxyBrowseExpandConcurrentCallersOnlyFireOneRpc(t *testing.T) {
	fake := &fakeGalaxyServer{
		browseChildrenReplies: []*pb.BrowseChildrenReply{
			// roots
			buildBrowseReply([]*pb.GalaxyObject{obj(1, "Plant", true)}, []bool{true}, 7),
			// one expand: one child
			buildBrowseReply([]*pb.GalaxyObject{obj(2, "Mixer", false)}, []bool{false}, 7),
		},
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	ctx := context.Background()
	roots, err := client.Browse(ctx, nil)
	if err != nil {
		t.Fatalf("Browse: %v", err)
	}

	var wg sync.WaitGroup
	errs := make(chan error, 10)
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			errs <- roots[0].Expand(ctx)
		}()
	}
	wg.Wait()
	close(errs)
	for err := range errs {
		if err != nil {
			t.Fatalf("concurrent Expand: %v", err)
		}
	}

	if !roots[0].IsExpanded() {
		t.Fatal("IsExpanded() = false after 10 concurrent expands")
	}
	if got, want := len(roots[0].Children()), 1; got != want {
		t.Fatalf("len(children) = %d, want %d", got, want)
	}
	// 1 roots fetch + exactly 1 expand fetch.
	if got, want := len(fake.browseChildrenCalls), 2; got != want {
		t.Fatalf("RPC count = %d, want %d", got, want)
	}
}

func TestGalaxyBrowseChildrenRejectsRepeatedPageToken(t *testing.T) {
	// Build a reply that carries a non-empty NextPageToken so browseChildrenInner
	// will request a second page. Queue the same reply twice so the second response
	// returns the same page token, triggering the duplicate-token guard.
	page := buildBrowseReply(
		[]*pb.GalaxyObject{obj(1, "Plant", true)},
		[]bool{true},
		1,
	)
	page.NextPageToken = "1:abc:1"

	fake := &fakeGalaxyServer{
		browseChildrenReplies: []*pb.BrowseChildrenReply{page, page},
	}
	client, cleanup := newGalaxyBufconnClient(t, fake)
	defer cleanup()

	_, err := client.Browse(context.Background(), nil)
	if err == nil {
		t.Fatal("Browse: error = nil, want repeated-page-token error")
	}
	var gwErr *GatewayError
	if !errors.As(err, &gwErr) {
		t.Fatalf("error type = %T, want *GatewayError; err = %v", err, err)
	}
}
