package externalworkload

import (
	"context"
	"fmt"
	"reflect"
	"sort"
	"testing"
	"time"

	ewv1beta1 "github.com/linkerd/linkerd2/controller/gen/apis/externalworkload/v1beta1"
	"github.com/linkerd/linkerd2/controller/k8s"
	v1 "k8s.io/api/core/v1"
	discoveryv1 "k8s.io/api/discovery/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/types"
	"k8s.io/apimachinery/pkg/util/intstr"
	k8stesting "k8s.io/client-go/testing"
	"k8s.io/client-go/tools/cache"
	"k8s.io/utils/ptr"
)

type endpointSliceController struct {
	*EndpointsController
	endpointSliceStore     cache.Store
	externalWorkloadsStore cache.Store
	serviceStore           cache.Store
}

func newController(t *testing.T) (*k8s.API, func() []k8stesting.Action, *endpointSliceController) {
	t.Helper()

	k8sAPI, actions, err := k8s.NewFakeAPIWithActions()
	if err != nil {
		t.Fatalf("unexpected error %v", err)
	}

	esController, err := NewEndpointsController(k8sAPI, "hostname", "linkerd", make(chan struct{}), false)
	if err != nil {
		t.Fatalf("unexpected error %v", err)
	}

	return k8sAPI, actions, &endpointSliceController{
		esController,
		k8sAPI.ES().Informer().GetStore(),
		k8sAPI.ExtWorkload().Informer().GetStore(),
		k8sAPI.Svc().Informer().GetStore(),
	}

}

func newExternalWorkload(n int, namespace string, ready bool, terminating bool) *ewv1beta1.ExternalWorkload {
	status := ewv1beta1.ConditionTrue
	if !ready {
		status = ewv1beta1.ConditionFalse
	}

	var deletionTimestamp *metav1.Time
	if terminating {
		deletionTimestamp = &metav1.Time{
			Time: time.Now(),
		}
	}

	ew := &ewv1beta1.ExternalWorkload{
		TypeMeta: metav1.TypeMeta{APIVersion: "v1"},
		ObjectMeta: metav1.ObjectMeta{
			Namespace:         namespace,
			Name:              fmt.Sprintf("ew-%d", n),
			Labels:            map[string]string{"foo": "bar"},
			DeletionTimestamp: deletionTimestamp,
			ResourceVersion:   fmt.Sprint(n),
		},
		Spec: ewv1beta1.ExternalWorkloadSpec{
			Ports: []ewv1beta1.PortSpec{
				{
					Name: "name",
					Port: 444,
				},
			},
			WorkloadIPs: []ewv1beta1.WorkloadIP{
				{Ip: "1.2.3.4"},
			},
		},
		Status: ewv1beta1.ExternalWorkloadStatus{
			Conditions: []ewv1beta1.WorkloadCondition{
				{
					Type:   ewv1beta1.WorkloadReady,
					Status: status,
				},
			},
		},
	}

	return ew
}

// Ensure SyncService for service with no selector results in no action
func TestSyncServiceNoSelector(t *testing.T) {
	ns := metav1.NamespaceDefault
	serviceName := "testing-1"
	_, actions, esController := newController(t)
	esController.serviceStore.Add(&v1.Service{
		ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: ns},
		Spec: v1.ServiceSpec{
			Ports: []v1.ServicePort{{TargetPort: intstr.FromInt32(80)}},
		},
	})

	err := esController.syncService(fmt.Sprintf("%s/%s", ns, serviceName))
	if err != nil {
		t.Errorf("unexpected error: %s", err)
	}

	if len(actions()) != 0 {
		t.Errorf("expected 0 actions, got: %d", len(actions()))
	}
}

func TestServiceExternalNameTypeSync(t *testing.T) {
	serviceName := "testing-1"
	namespace := "zahari"

	testCases := []struct {
		desc    string
		service *v1.Service
	}{
		{
			desc: "External name with selector and ports should not receive endpoint slices",
			service: &v1.Service{
				ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace},
				Spec: v1.ServiceSpec{
					Selector: map[string]string{"foo": "bar"},
					Ports:    []v1.ServicePort{{Port: 80}},
					Type:     v1.ServiceTypeExternalName,
				},
			},
		},
		{
			desc: "External name with ports should not receive endpoint slices",
			service: &v1.Service{
				ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace},
				Spec: v1.ServiceSpec{
					Ports: []v1.ServicePort{{Port: 80}},
					Type:  v1.ServiceTypeExternalName,
				},
			},
		},
		{
			desc: "External name with selector should not receive endpoint slices",
			service: &v1.Service{
				ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace},
				Spec: v1.ServiceSpec{
					Selector: map[string]string{"foo": "bar"},
					Type:     v1.ServiceTypeExternalName,
				},
			},
		},
		{
			desc: "External name without selector and ports should not receive endpoint slices",
			service: &v1.Service{
				ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace},
				Spec: v1.ServiceSpec{
					Type: v1.ServiceTypeExternalName,
				},
			},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.desc, func(t *testing.T) {
			client, actions, esController := newController(t)
			ew := newExternalWorkload(1, namespace, true, false)
			err := esController.externalWorkloadsStore.Add(ew)
			if err != nil {
				t.Errorf("unexpected error: %s", err)
			}

			err = esController.serviceStore.Add(tc.service)
			if err != nil {
				t.Errorf("unexpected error: %s", err)
			}

			err = esController.syncService(fmt.Sprintf("%s/%s", namespace, serviceName))
			if err != nil {
				t.Errorf("unexpected error: %s", err)
			}

			if len(actions()) != 0 {
				t.Errorf("expected 0 actions, got: %d", len(actions()))
			}

			sliceList, err := client.Client.DiscoveryV1().EndpointSlices(namespace).List(context.TODO(), metav1.ListOptions{})
			if err != nil {
				t.Errorf("unexpected error: %s", err)
			}
			if len(sliceList.Items) != 0 {
				t.Errorf("Expected 0 endpoint slices, got: %d", len(sliceList.Items))
			}
		})
	}
}

// Ensure SyncService for service with pending deletion results in no action
func TestSyncServicePendingDeletion(t *testing.T) {
	ns := metav1.NamespaceDefault
	serviceName := "testing-1"
	deletionTimestamp := metav1.Now()
	_, actions, esController := newController(t)
	esController.serviceStore.Add(&v1.Service{
		ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: ns, DeletionTimestamp: &deletionTimestamp},
		Spec: v1.ServiceSpec{
			Selector: map[string]string{"foo": "bar"},
			Ports:    []v1.ServicePort{{TargetPort: intstr.FromInt32(80)}},
		},
	})

	err := esController.syncService(fmt.Sprintf("%s/%s", ns, serviceName))
	if err != nil {
		t.Errorf("unexpected error: %s", err)
	}
	if len(actions()) != 0 {
		t.Errorf("Expected 0 actions, got: %d", len(actions()))
	}
}

// Ensure SyncService correctly selects ExternalWorkload.
func TestSyncServiceExternalWorkloadSelection(t *testing.T) {
	client, actions, esController := newController(t)
	ns := "test-ns"

	ew1 := newExternalWorkload(1, ns, true, false)
	esController.externalWorkloadsStore.Add(ew1)

	// ensure this ew will not match the selector
	ew2 := newExternalWorkload(2, ns, true, false)
	ew2.Labels["foo"] = "boo"
	esController.externalWorkloadsStore.Add(ew2)

	standardSyncService(t, esController, ns, "testing-1")
	expectActions(t, actions(), 1, "create", "endpointslices")

	// an endpoint slice should be created, it should only reference ew1 (not ew2)
	slices, err := client.Client.DiscoveryV1().EndpointSlices(ns).List(context.TODO(), metav1.ListOptions{})
	if err != nil {
		t.Errorf("Expected no error fetching endpoint slices, got: %s", err)
	}
	if len(slices.Items) != 1 {
		t.Errorf("Expected 1 endpoint slices, got: %d", len(slices.Items))
	}

	slice := slices.Items[0]
	if len(slice.Endpoints) != 1 {
		t.Errorf("Expected 1 endpoint in first slice, got: %d", len(slice.Endpoints))
	}
	endpoint := slice.Endpoints[0]
	if endpoint.TargetRef.Kind != "ExternalWorkload" || endpoint.TargetRef.Namespace != ns || endpoint.TargetRef.Name != ew1.Name {
		t.Errorf("Expected endpoint to target ExternalWorkload")
	}
}

func TestSyncServiceEndpointSlicePendingDeletion(t *testing.T) {
	client, actions, esController := newController(t)
	ns := "test-ns"
	serviceName := "testing-1"
	service := createService(t, esController, ns, serviceName)
	err := esController.syncService(fmt.Sprintf("%s/%s", ns, serviceName))
	if err != nil {
		t.Fatalf("Expected no error creating EndpointSlice: %v", err)
	}

	gvk := schema.GroupVersionKind{Version: "v1", Kind: "Service"}
	ownerRef := metav1.NewControllerRef(service, gvk)

	deletedTs := metav1.Now()
	endpointSlice := &discoveryv1.EndpointSlice{
		ObjectMeta: metav1.ObjectMeta{
			Name:            "epSlice-1",
			Namespace:       ns,
			OwnerReferences: []metav1.OwnerReference{*ownerRef},
			Labels: map[string]string{
				discoveryv1.LabelServiceName: serviceName,
				discoveryv1.LabelManagedBy:   managedBy,
			},
			DeletionTimestamp: &deletedTs,
		},
		AddressType: discoveryv1.AddressTypeIPv4,
	}
	err = esController.endpointSliceStore.Add(endpointSlice)
	if err != nil {
		t.Fatalf("Expected no error adding EndpointSlice: %v", err)
	}
	_, err = client.Client.DiscoveryV1().EndpointSlices(ns).Create(context.TODO(), endpointSlice, metav1.CreateOptions{})
	if err != nil {
		t.Fatalf("Expected no error creating EndpointSlice: %v", err)
	}

	numActionsBefore := len(actions())
	err = esController.syncService(fmt.Sprintf("%s/%s", ns, serviceName))
	if err != nil {
		t.Errorf("Expected no error syncing service, got: %s", err)
	}

	// The EndpointSlice marked for deletion should be ignored by the controller, and thus
	// should not result in any action.
	if len(actions()) != numActionsBefore {
		t.Errorf("Expected 0 more actions, got %d", len(actions())-numActionsBefore)
	}
}

func makeExternalWorkload(resVersion, name string, labels map[string]string, ports map[int32]string, ips []string) *ewv1beta1.ExternalWorkload {
	portSpecs := []ewv1beta1.PortSpec{}
	for port, name := range ports {
		spec := ewv1beta1.PortSpec{
			Port: port,
		}
		if name != "" {
			spec.Name = name
		}
		portSpecs = append(portSpecs, spec)
	}

	wIps := []ewv1beta1.WorkloadIP{}
	for _, ip := range ips {
		wIps = append(wIps, ewv1beta1.WorkloadIP{Ip: ip})
	}

	ew := &ewv1beta1.ExternalWorkload{
		ObjectMeta: metav1.ObjectMeta{
			Name:            name,
			Namespace:       "ns",
			Labels:          labels,
			ResourceVersion: resVersion,
		},
		Spec: ewv1beta1.ExternalWorkloadSpec{
			MeshTLS: ewv1beta1.MeshTLS{
				Identity:   "some-identity",
				ServerName: "some-sni",
			},
			Ports:       portSpecs,
			WorkloadIPs: wIps,
		},
		Status: ewv1beta1.ExternalWorkloadStatus{
			Conditions: []ewv1beta1.WorkloadCondition{
				{
					Type:   ewv1beta1.WorkloadReady,
					Status: ewv1beta1.ConditionTrue,
				},
			},
		},
	}

	ew.ObjectMeta.UID = types.UID(fmt.Sprintf("%s-%s", ew.Namespace, ew.Name))
	return ew
}

func TestSyncService(t *testing.T) {
	creationTimestamp := metav1.Now()
	namespace := "test-ns"
	testcases := []struct {
		name                  string
		service               *v1.Service
		externalWorkloads     []*ewv1beta1.ExternalWorkload
		expectedEndpointPorts []discoveryv1.EndpointPort
		expectedEndpoints     []discoveryv1.Endpoint
	}{
		{
			name: "EW with multiple IPs and Service with ipFamilies=ipv4",
			service: &v1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:              "foobar",
					Namespace:         namespace,
					CreationTimestamp: creationTimestamp,
				},
				Spec: v1.ServiceSpec{
					Ports: []v1.ServicePort{
						{Name: "tcp-example", TargetPort: intstr.FromInt32(80), Protocol: v1.ProtocolTCP},
						{Name: "udp-example", TargetPort: intstr.FromInt32(161), Protocol: v1.ProtocolUDP},
						{Name: "sctp-example", TargetPort: intstr.FromInt32(3456), Protocol: v1.ProtocolSCTP},
					},
					Selector:   map[string]string{"foo": "bar"},
					IPFamilies: []v1.IPFamily{v1.IPv4Protocol},
				},
			},
			externalWorkloads: []*ewv1beta1.ExternalWorkload{
				{
					ObjectMeta: metav1.ObjectMeta{
						Namespace:         namespace,
						Name:              "ew0",
						Labels:            map[string]string{"foo": "bar"},
						DeletionTimestamp: nil,
					},
					Spec: ewv1beta1.ExternalWorkloadSpec{
						WorkloadIPs: []ewv1beta1.WorkloadIP{
							{
								Ip: "10.0.0.1",
							},
						},
						Ports: []ewv1beta1.PortSpec{
							{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
							{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
							{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
						},
					},
					Status: ewv1beta1.ExternalWorkloadStatus{
						Conditions: []ewv1beta1.WorkloadCondition{
							{
								Type:   ewv1beta1.WorkloadReady,
								Status: ewv1beta1.ConditionTrue,
							},
						},
					},
				},
				{
					ObjectMeta: metav1.ObjectMeta{
						Namespace:         namespace,
						Name:              "ew1",
						Labels:            map[string]string{"foo": "bar"},
						DeletionTimestamp: nil,
					},

					Spec: ewv1beta1.ExternalWorkloadSpec{
						WorkloadIPs: []ewv1beta1.WorkloadIP{
							{
								Ip: "10.0.0.2",
							},
							{
								Ip: "fd08::5678:0000:0000:9abc:def0",
							},
						},
						Ports: []ewv1beta1.PortSpec{
							{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
							{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
							{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
						},
					},
					Status: ewv1beta1.ExternalWorkloadStatus{
						Conditions: []ewv1beta1.WorkloadCondition{
							{
								Type:   ewv1beta1.WorkloadReady,
								Status: ewv1beta1.ConditionTrue,
							},
						},
					},
				},
			},
			expectedEndpointPorts: []discoveryv1.EndpointPort{
				{
					Name:     ptr.To("udp-example"),
					Protocol: protoPtr(v1.ProtocolUDP),
					Port:     ptr.To(int32(161)),
				},
				{
					Name:     ptr.To("sctp-example"),
					Protocol: protoPtr(v1.ProtocolSCTP),
					Port:     ptr.To(int32(3456)),
				},
				{
					Name:     ptr.To("tcp-example"),
					Protocol: protoPtr(v1.ProtocolTCP),
					Port:     ptr.To(int32(80)),
				},
			},
			expectedEndpoints: []discoveryv1.Endpoint{
				{
					Conditions: discoveryv1.EndpointConditions{
						Ready:       ptr.To(true),
						Serving:     ptr.To(true),
						Terminating: ptr.To(false),
					},
					Addresses: []string{"10.0.0.1"},
					TargetRef: &v1.ObjectReference{Kind: "ExternalWorkload", Namespace: namespace, Name: "ew0"},
				},
				{
					Conditions: discoveryv1.EndpointConditions{
						Ready:       ptr.To(true),
						Serving:     ptr.To(true),
						Terminating: ptr.To(false),
					},
					Addresses: []string{"10.0.0.2"},
					TargetRef: &v1.ObjectReference{Kind: "ExternalWorkload", Namespace: namespace, Name: "ew1"},
				},
			},
		},
		{
			name: "EWs with multiple IPs and Service with ipFamilies=ipv6",
			service: &v1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:              "foobar",
					Namespace:         namespace,
					CreationTimestamp: creationTimestamp,
				},
				Spec: v1.ServiceSpec{
					Ports: []v1.ServicePort{
						{Name: "tcp-example", TargetPort: intstr.FromInt32(80), Protocol: v1.ProtocolTCP},
						{Name: "udp-example", TargetPort: intstr.FromInt32(161), Protocol: v1.ProtocolUDP},
						{Name: "sctp-example", TargetPort: intstr.FromInt32(3456), Protocol: v1.ProtocolSCTP},
					},
					Selector:   map[string]string{"foo": "bar"},
					IPFamilies: []v1.IPFamily{v1.IPv6Protocol},
				},
			},
			externalWorkloads: []*ewv1beta1.ExternalWorkload{
				{
					ObjectMeta: metav1.ObjectMeta{
						Namespace:         namespace,
						Name:              "ew0",
						Labels:            map[string]string{"foo": "bar"},
						DeletionTimestamp: nil,
					},
					Spec: ewv1beta1.ExternalWorkloadSpec{
						WorkloadIPs: []ewv1beta1.WorkloadIP{
							{
								Ip: "10.0.0.1",
							},
						},
						Ports: []ewv1beta1.PortSpec{
							{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
							{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
							{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
						},
					},
					Status: ewv1beta1.ExternalWorkloadStatus{
						Conditions: []ewv1beta1.WorkloadCondition{
							{
								Type:   ewv1beta1.WorkloadReady,
								Status: ewv1beta1.ConditionTrue,
							},
						},
					},
				},
				{
					ObjectMeta: metav1.ObjectMeta{
						Namespace:         namespace,
						Name:              "ew1",
						Labels:            map[string]string{"foo": "bar"},
						DeletionTimestamp: nil,
					},
					Spec: ewv1beta1.ExternalWorkloadSpec{
						WorkloadIPs: []ewv1beta1.WorkloadIP{
							{
								Ip: "10.0.0.2",
							},
							{

								Ip: "fd08::5678:0000:0000:9abc:def0",
							},
						},
						Ports: []ewv1beta1.PortSpec{
							{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
							{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
							{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
						},
					},
					Status: ewv1beta1.ExternalWorkloadStatus{
						Conditions: []ewv1beta1.WorkloadCondition{
							{
								Type:   ewv1beta1.WorkloadReady,
								Status: ewv1beta1.ConditionTrue,
							},
						},
					},
				},
			},
			expectedEndpointPorts: []discoveryv1.EndpointPort{
				{
					Name:     ptr.To("udp-example"),
					Protocol: protoPtr(v1.ProtocolUDP),
					Port:     ptr.To(int32(161)),
				},
				{
					Name:     ptr.To("sctp-example"),
					Protocol: protoPtr(v1.ProtocolSCTP),
					Port:     ptr.To(int32(3456)),
				},
				{
					Name:     ptr.To("tcp-example"),
					Protocol: protoPtr(v1.ProtocolTCP),
					Port:     ptr.To(int32(80)),
				},
			},
			expectedEndpoints: []discoveryv1.Endpoint{
				{
					Conditions: discoveryv1.EndpointConditions{
						Ready:       ptr.To(true),
						Serving:     ptr.To(true),
						Terminating: ptr.To(false),
					},
					Addresses: []string{"fd08::5678:0000:0000:9abc:def0"},
					TargetRef: &v1.ObjectReference{Kind: "ExternalWorkload", Namespace: namespace, Name: "ew1"},
				},
			},
		},
		{
			name: "Not ready workloads",
			service: &v1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:              "foobar",
					Namespace:         namespace,
					CreationTimestamp: creationTimestamp,
				},
				Spec: v1.ServiceSpec{
					Ports: []v1.ServicePort{
						{Name: "tcp-example", TargetPort: intstr.FromInt32(80), Protocol: v1.ProtocolTCP},
						{Name: "udp-example", TargetPort: intstr.FromInt32(161), Protocol: v1.ProtocolUDP},
						{Name: "sctp-example", TargetPort: intstr.FromInt32(3456), Protocol: v1.ProtocolSCTP},
					},
					Selector:   map[string]string{"foo": "bar"},
					IPFamilies: []v1.IPFamily{v1.IPv4Protocol},
				},
			},
			externalWorkloads: []*ewv1beta1.ExternalWorkload{
				{
					ObjectMeta: metav1.ObjectMeta{
						Namespace:         namespace,
						Name:              "ew0",
						Labels:            map[string]string{"foo": "bar"},
						DeletionTimestamp: nil,
					},
					Spec: ewv1beta1.ExternalWorkloadSpec{
						WorkloadIPs: []ewv1beta1.WorkloadIP{
							{
								Ip: "10.0.0.1",
							},
						},
						Ports: []ewv1beta1.PortSpec{
							{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
							{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
							{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
						},
					},
					Status: ewv1beta1.ExternalWorkloadStatus{
						Conditions: []ewv1beta1.WorkloadCondition{
							{
								Type:   ewv1beta1.WorkloadReady,
								Status: ewv1beta1.ConditionTrue,
							},
						},
					},
				},
				{
					ObjectMeta: metav1.ObjectMeta{
						Namespace:         namespace,
						Name:              "ew1",
						Labels:            map[string]string{"foo": "bar"},
						DeletionTimestamp: nil,
					},
					Spec: ewv1beta1.ExternalWorkloadSpec{
						WorkloadIPs: []ewv1beta1.WorkloadIP{
							{
								Ip: "10.0.0.2",
							},
							{

								Ip: "fd08::5678:0000:0000:9abc:def0",
							},
						},
						Ports: []ewv1beta1.PortSpec{
							{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
							{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
							{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
						},
					},
					Status: ewv1beta1.ExternalWorkloadStatus{
						Conditions: []ewv1beta1.WorkloadCondition{
							{
								Type:   ewv1beta1.WorkloadReady,
								Status: ewv1beta1.ConditionFalse,
							},
						},
					},
				},
			},
			expectedEndpointPorts: []discoveryv1.EndpointPort{
				{
					Name:     ptr.To("udp-example"),
					Protocol: protoPtr(v1.ProtocolUDP),
					Port:     ptr.To(int32(161)),
				},
				{
					Name:     ptr.To("sctp-example"),
					Protocol: protoPtr(v1.ProtocolSCTP),
					Port:     ptr.To(int32(3456)),
				},
				{
					Name:     ptr.To("tcp-example"),
					Protocol: protoPtr(v1.ProtocolTCP),
					Port:     ptr.To(int32(80)),
				},
			},
			expectedEndpoints: []discoveryv1.Endpoint{
				{
					Conditions: discoveryv1.EndpointConditions{
						Ready:       ptr.To(true),
						Serving:     ptr.To(true),
						Terminating: ptr.To(false),
					},
					Addresses: []string{"10.0.0.1"},
					TargetRef: &v1.ObjectReference{Kind: "ExternalWorkload", Namespace: namespace, Name: "ew0"},
				},
				{
					Conditions: discoveryv1.EndpointConditions{
						Ready:       ptr.To(false),
						Serving:     ptr.To(false),
						Terminating: ptr.To(false),
					},
					Addresses: []string{"10.0.0.2"},
					TargetRef: &v1.ObjectReference{Kind: "ExternalWorkload", Namespace: namespace, Name: "ew1"},
				},
			},
		},
		{
			name: "Two Ready workloads with the same IPs",
			service: &v1.Service{
				ObjectMeta: metav1.ObjectMeta{
					Name:              "foobar",
					Namespace:         namespace,
					CreationTimestamp: creationTimestamp,
				},
				Spec: v1.ServiceSpec{
					Ports: []v1.ServicePort{
						{Name: "tcp-example", TargetPort: intstr.FromInt32(80), Protocol: v1.ProtocolTCP},
						{Name: "udp-example", TargetPort: intstr.FromInt32(161), Protocol: v1.ProtocolUDP},
						{Name: "sctp-example", TargetPort: intstr.FromInt32(3456), Protocol: v1.ProtocolSCTP},
					},
					Selector:   map[string]string{"foo": "bar"},
					IPFamilies: []v1.IPFamily{v1.IPv4Protocol},
				},
			},
			externalWorkloads: []*ewv1beta1.ExternalWorkload{
				{
					ObjectMeta: metav1.ObjectMeta{
						Namespace:         namespace,
						Name:              "ew0",
						Labels:            map[string]string{"foo": "bar"},
						DeletionTimestamp: nil,
					},
					Spec: ewv1beta1.ExternalWorkloadSpec{
						WorkloadIPs: []ewv1beta1.WorkloadIP{
							{
								Ip: "10.0.0.1",
							},
						},
						Ports: []ewv1beta1.PortSpec{
							{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
							{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
							{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
						},
					},
					Status: ewv1beta1.ExternalWorkloadStatus{
						Conditions: []ewv1beta1.WorkloadCondition{
							{
								Type:   ewv1beta1.WorkloadReady,
								Status: ewv1beta1.ConditionTrue,
							},
						},
					},
				},
				{
					ObjectMeta: metav1.ObjectMeta{
						Namespace:         namespace,
						Name:              "ew1",
						Labels:            map[string]string{"foo": "bar"},
						DeletionTimestamp: nil,
					},
					Spec: ewv1beta1.ExternalWorkloadSpec{
						WorkloadIPs: []ewv1beta1.WorkloadIP{
							{
								Ip: "10.0.0.1",
							},
						},
						Ports: []ewv1beta1.PortSpec{
							{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
							{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
							{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
						},
					},
					Status: ewv1beta1.ExternalWorkloadStatus{
						Conditions: []ewv1beta1.WorkloadCondition{
							{
								Type:   ewv1beta1.WorkloadReady,
								Status: ewv1beta1.ConditionTrue,
							},
						},
					},
				},
			},
			expectedEndpointPorts: []discoveryv1.EndpointPort{
				{
					Name:     ptr.To("udp-example"),
					Protocol: protoPtr(v1.ProtocolUDP),
					Port:     ptr.To(int32(161)),
				},
				{
					Name:     ptr.To("sctp-example"),
					Protocol: protoPtr(v1.ProtocolSCTP),
					Port:     ptr.To(int32(3456)),
				},
				{
					Name:     ptr.To("tcp-example"),
					Protocol: protoPtr(v1.ProtocolTCP),
					Port:     ptr.To(int32(80)),
				},
			},
			expectedEndpoints: []discoveryv1.Endpoint{
				{
					Conditions: discoveryv1.EndpointConditions{
						Ready:       ptr.To(true),
						Serving:     ptr.To(true),
						Terminating: ptr.To(false),
					},
					Addresses: []string{"10.0.0.1"},
					TargetRef: &v1.ObjectReference{Kind: "ExternalWorkload", Namespace: namespace, Name: "ew0"},
				},
				{
					Conditions: discoveryv1.EndpointConditions{
						Ready:       ptr.To(true),
						Serving:     ptr.To(true),
						Terminating: ptr.To(false),
					},
					Addresses: []string{"10.0.0.1"},
					TargetRef: &v1.ObjectReference{Kind: "ExternalWorkload", Namespace: namespace, Name: "ew1"},
				},
			},
		},
	}

	for _, testcase := range testcases {
		t.Run(testcase.name, func(t *testing.T) {
			client, actions, esController := newController(t)

			for _, ew := range testcase.externalWorkloads {
				esController.externalWorkloadsStore.Add(ew)
			}
			esController.serviceStore.Add(testcase.service)

			_, err := esController.k8sAPI.Client.CoreV1().Services(testcase.service.Namespace).Create(context.TODO(), testcase.service, metav1.CreateOptions{})
			if err != nil {
				t.Errorf("Expected no error creating service, got: %s", err)
			}
			err = esController.syncService(fmt.Sprintf("%s/%s", testcase.service.Namespace, testcase.service.Name))
			if err != nil {
				t.Errorf("Expected no error, got: %s", err)
			}

			// last action should be to create endpoint slice
			expectActions(t, actions(), 1, "create", "endpointslices")
			sliceList, err := client.Client.DiscoveryV1().EndpointSlices(testcase.service.Namespace).List(context.TODO(), metav1.ListOptions{})
			if err != nil {
				t.Errorf("Expected no error fetching endpoint slices, got: %s", err)
			}

			if len(sliceList.Items) != 1 {
				t.Errorf("Expected 1 endpoints slices")
			}

			// ensure all attributes of endpoint slice match expected state
			slice := sliceList.Items[0]

			// check expected ports

			if !reflect.DeepEqual(testcase.expectedEndpointPorts, slice.Ports) {
				t.Error("actual ports do not match expected ones")
			}

			// sort actual endpoints in terms of targetRef name, in ascending
			// order. This will ensure reflection package doesn't give a
			// spurious error.

			sort.Slice(slice.Endpoints, func(i, j int) bool {
				return slice.Endpoints[i].TargetRef.Name < slice.Endpoints[j].TargetRef.Name
			})

			if !reflect.DeepEqual(testcase.expectedEndpoints, slice.Endpoints) {
				t.Error("actual endpoints do not match expected ones")
			}
		})
	}
}

// Test diffing logic that determines if two workloads with the same name and
// namespace have changed enough to warrant reconciliation
// TODO: Add tests for labels change
func TestEwEndpointsChanged(t *testing.T) {
	for _, tt := range []struct {
		name        string
		old         *ewv1beta1.ExternalWorkload
		updated     *ewv1beta1.ExternalWorkload
		specChanged bool
	}{
		{
			name: "no change",
			old: makeExternalWorkload(
				"1",
				"wlkd1",
				nil,
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			updated: makeExternalWorkload(
				"1",
				"wlkd1",
				nil,
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			specChanged: false,
		},
		{
			name: "updated workload adds an IP address",
			old: makeExternalWorkload(
				"1",
				"wlkd1",
				nil,
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			updated: makeExternalWorkload(
				"2",
				"wlkd1",
				nil,
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0", "192.0.3.0"},
			),
			specChanged: true,
		},
		{
			name: "updated workload removes an IP address",
			old: makeExternalWorkload(
				"1",
				"wlkd1",
				nil,
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0", "192.0.3.0"},
			),
			updated: makeExternalWorkload(
				"2",
				"wlkd1",
				nil,
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			specChanged: true,
		},
		{
			name: "updated workload changes an IP address",
			old: makeExternalWorkload(
				"1",
				"wlkd1",
				nil,
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			updated: makeExternalWorkload(
				"2",
				"wlkd1",
				nil,
				map[int32]string{1: "port-1"},
				[]string{"192.0.3.0"},
			),
			specChanged: true,
		},
		{
			name: "updated workload adds new port",
			old: makeExternalWorkload(
				"1",
				"wlkd1",
				nil,
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			updated: makeExternalWorkload(
				"2",
				"wlkd1",
				nil,
				map[int32]string{1: "port-1", 2: "port-2"},
				[]string{"192.0.2.0"},
			),
			specChanged: true,
		},
		{
			name: "updated workload removes port",
			old: makeExternalWorkload(
				"1",
				"wlkd1",
				nil,
				map[int32]string{1: "port-1", 2: "port-2"},
				[]string{"192.0.2.0"},
			),
			updated: makeExternalWorkload(
				"2",
				"wlkd1",
				nil,
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			specChanged: true,
		},
		{
			name: "updated workload changes port number",
			old: makeExternalWorkload(
				"1",
				"wlkd1",
				nil,
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			updated: makeExternalWorkload(
				"2",
				"wlkd1",
				nil,
				map[int32]string{2: "port-1"},
				[]string{"192.0.2.0"},
			),
			specChanged: true,
		},
		{
			name: "updated workload changes port name",
			old: makeExternalWorkload(
				"1",
				"wlkd1",
				nil,
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			updated: makeExternalWorkload(
				"2",
				"wlkd1",
				nil,
				map[int32]string{1: "port-foo"},
				[]string{"192.0.2.0"},
			),
			specChanged: true,
		},
		{
			name: "updated workload removes port name",
			old: makeExternalWorkload(
				"1",
				"wlkd1",
				nil,
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			updated: makeExternalWorkload(
				"2",
				"wlkd1",
				nil,
				map[int32]string{1: ""},
				[]string{"192.0.2.0"},
			),
			specChanged: true,
		},
	} {
		tt := tt // Pin
		t.Run(tt.name, func(t *testing.T) {
			specChanged, _ := ewEndpointsChanged(tt.old, tt.updated)
			if tt.specChanged != specChanged {
				t.Errorf("expected specChanged '%v', got '%v'", tt.specChanged, specChanged)
			}
		})
	}
}

// Test diffing logic that determines if two workloads with the same name and
// namespace have changed enough to warrant reconciliation
func TestWorkloadServicesToUpdate(t *testing.T) {
	for _, tt := range []struct {
		name           string
		old            *ewv1beta1.ExternalWorkload
		updated        *ewv1beta1.ExternalWorkload
		k8sConfigs     []string
		expectServices map[string]struct{}
	}{
		{
			name: "no change",
			old: makeExternalWorkload(
				"1",
				"wlkd1",
				map[string]string{"app": "test"},
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			updated: makeExternalWorkload(
				"1",
				"wlkd1",
				map[string]string{"app": "test"},
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			k8sConfigs: []string{`
            apiVersion: v1
            kind: Service
            metadata:
              name: svc-1
              namespace: ns
            spec:
              selector:
                app: test`,
			},
			expectServices: map[string]struct{}{},
		},
		{
			name: "labels and spec have changed",
			old: makeExternalWorkload(
				"1",
				"wlkd1",
				map[string]string{"app": "test-1"},
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			updated: makeExternalWorkload(
				"2",
				"wlkd1",
				map[string]string{"app": "test-2"},
				map[int32]string{2: "port-1"},
				[]string{"192.0.2.0"},
			),
			k8sConfigs: []string{`
            apiVersion: v1
            kind: Service
            metadata:
              name: svc-1
              namespace: ns
            spec:
              selector:
                app: test-1`, `
            apiVersion: v1
            kind: Service
            metadata:
              name: svc-2
              namespace: ns
            spec:
              selector:
                app: test-2`,
			},
			expectServices: map[string]struct{}{"ns/svc-1": {}, "ns/svc-2": {}},
		},
		{
			name: "spec has changed",
			old: makeExternalWorkload(
				"1",
				"wlkd1",
				map[string]string{"app": "test-1"},
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			updated: makeExternalWorkload(
				"2",
				"wlkd1",
				map[string]string{"app": "test-1"},
				map[int32]string{2: "port-1"},
				[]string{"192.0.2.0"},
			),
			k8sConfigs: []string{`
            apiVersion: v1
            kind: Service
            metadata:
              name: svc-1
              namespace: ns
            spec:
              selector:
                app: test-1`,
			},
			expectServices: map[string]struct{}{"ns/svc-1": {}},
		},
		{
			name: "labels have changed",
			old: makeExternalWorkload(
				"1",
				"wlkd1",
				map[string]string{"app": "test-1", "env": "staging"},
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			updated: makeExternalWorkload(
				"2",
				"wlkd1",
				map[string]string{"app": "test-1", "env": "prod"},
				map[int32]string{1: "port-1"},
				[]string{"192.0.2.0"},
			),
			k8sConfigs: []string{`
            apiVersion: v1
            kind: Service
            metadata:
              name: internal
              namespace: ns
            spec:
              selector:
                app: test-1`, `
            apiVersion: v1
            kind: Service
            metadata:
              name: staging
              namespace: ns
            spec:
              selector:
                env: staging`, `
            apiVersion: v1
            kind: Service
            metadata:
              name: prod
              namespace: ns
            spec:
              selector:
                env: prod`,
			},
			expectServices: map[string]struct{}{"ns/staging": {}, "ns/prod": {}},
		}} {
		tt := tt // Pin
		t.Run(tt.name, func(t *testing.T) {
			k8sAPI, err := k8s.NewFakeAPI(tt.k8sConfigs...)
			if err != nil {
				t.Fatalf("unexpected error %v", err)
			}

			ec, err := NewEndpointsController(k8sAPI, "my-hostname", "controlplane-ns", make(chan struct{}), false)
			if err != nil {
				t.Fatalf("unexpected error %v", err)
			}

			ec.Start()
			k8sAPI.Sync(nil)

			services := ec.getServicesToUpdateOnExternalWorkloadChange(tt.old, tt.updated)
			if len(services) != len(tt.expectServices) {
				t.Fatalf("expected %d services to update, got %d services instead", len(tt.expectServices), len(services))
			}

			for svc := range services {
				if _, ok := tt.expectServices[svc]; !ok {
					t.Errorf("unexpected service key %s found in list of results", svc)
				}
			}
		})

	}
}

// Assert that de-registering handlers won't result in cache staleness issues
//
// The test will simulate a scenario where a lease is acquired, an endpointslice
// created, and the lease is lost. Without wiping out state, this test will
// fail, since any changes made to the resources will not be observed while the
// lease is not held; these changes will result in stale cache entries (since
// the state diverged).
func TestLeaderElectionSyncsState(t *testing.T) {
	client, actions, esController := newController(t)
	ns := "test-ns"
	service := createService(t, esController, ns, "test-svc")
	ew1 := newExternalWorkload(1, ns, false, true)
	esController.serviceStore.Add(service)
	esController.externalWorkloadsStore.Add(ew1)

	// Simulate a lease being acquired,
	err := esController.addHandlers()
	if err != nil {
		t.Fatalf("unexpected error when registering client-go callbacks: %v", err)
	}

	err = esController.syncService(fmt.Sprintf("%s/%s", ns, service.Name))
	if err != nil {
		t.Fatalf("unexpected error when processing service %s/%s: %v", ns, service.Name, err)
	}
	expectActions(t, actions(), 1, "create", "endpointslices")

	slices, err := client.Client.DiscoveryV1().EndpointSlices(ns).List(context.TODO(), metav1.ListOptions{})
	if err != nil {
		t.Errorf("expected no error fetching endpoint slices, got: %s", err)
	}
	if len(slices.Items) != 1 {
		t.Errorf("expected 1 endpoint slices, got: %d", len(slices.Items))
	}
	sliceName := slices.Items[0].Name

	// Simulate a lease being lost; we delete the previously created
	// endpointslice out-of-band.
	err = esController.removeHandlers()
	if err != nil {
		t.Fatalf("unexpected error when de-registering client-go callbacks: %v", err)
	}
	err = client.Client.DiscoveryV1().EndpointSlices(ns).Delete(context.TODO(), sliceName, metav1.DeleteOptions{})
	if err != nil {
		t.Fatalf("unexpected error when deleting endpointslice %s/%s: %v", ns, sliceName, err)
	}
	slices, err = client.Client.DiscoveryV1().EndpointSlices(ns).List(context.TODO(), metav1.ListOptions{})
	if err != nil {
		t.Errorf("expected no error fetching endpoint slices, got: %s", err)
	}
	if len(slices.Items) != 0 {
		t.Errorf("expected 0 endpoint slices, got: %d", len(slices.Items))
	}

	// The lease is re-acquired. We should start with a clean slate to avoid
	// cache staleness errors.
	esController.addHandlers()
	err = esController.syncService(fmt.Sprintf("%s/%s", ns, service.Name))
	if err != nil {
		t.Fatalf("unexpected error when processing service %s/%s: %v", ns, service.Name, err)
	}
	expectActions(t, actions(), 1, "create", "endpointslices")
	slices, err = client.Client.DiscoveryV1().EndpointSlices(ns).List(context.TODO(), metav1.ListOptions{})
	if err != nil {
		t.Errorf("expected no error fetching endpoint slices, got: %s", err)
	}
	if len(slices.Items) != 1 {
		t.Errorf("expected 1 endpoint slices, got: %d", len(slices.Items))
	}
	if slices.Items[0].Name == sliceName {
		t.Fatalf("expected newly created slice's name to be different than the initial slice, got: %s", sliceName)
	}

}

// protoPtr takes a Protocol and returns a pointer to it.
func protoPtr(proto v1.Protocol) *v1.Protocol {
	return &proto
}

func newStatusCondition(ready bool) ewv1beta1.WorkloadCondition {
	var status ewv1beta1.WorkloadConditionStatus
	if ready {
		status = ewv1beta1.ConditionTrue
	} else {
		status = ewv1beta1.ConditionFalse
	}
	return ewv1beta1.WorkloadCondition{
		Type:               ewv1beta1.WorkloadReady,
		Status:             status,
		LastProbeTime:      metav1.Time{},
		LastTransitionTime: metav1.NewTime(time.Now()),
		Reason:             "test",
		Message:            "test",
	}
}

//nolint:all
func expectActions(t *testing.T, actions []k8stesting.Action, num int, verb, resource string) {
	t.Helper()
	// if actions are less the below logic will panic
	if num > len(actions) {
		t.Fatalf("len of actions %v is unexpected. Expected to be at least %v", len(actions), num+1)
	}

	for i := 0; i < num; i++ {
		relativePos := len(actions) - i - 1
		if actions[relativePos].GetVerb() != verb {
			t.Errorf("Expected action -%d verb to be %s, was: %s", i, verb, actions[relativePos].GetVerb())
		}
		if resource != actions[relativePos].GetResource().Resource {
			t.Errorf("Expected action -%d resource to be %s, was: %s", i, resource, actions[relativePos].GetResource().Resource)
		}
	}
}

func createService(t *testing.T, esController *endpointSliceController, namespace, serviceName string) *v1.Service {
	t.Helper()
	service := &v1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:              serviceName,
			Namespace:         namespace,
			CreationTimestamp: metav1.NewTime(time.Now()),
			UID:               types.UID(namespace + "-" + serviceName),
		},
		Spec: v1.ServiceSpec{
			Ports:      []v1.ServicePort{{TargetPort: intstr.FromInt32(80)}},
			Selector:   map[string]string{"foo": "bar"},
			IPFamilies: []v1.IPFamily{v1.IPv4Protocol},
		},
	}
	esController.serviceStore.Add(service)
	_, err := esController.k8sAPI.Client.CoreV1().Services(namespace).Create(context.TODO(), service, metav1.CreateOptions{})
	if err != nil {
		t.Error("Expected no error creating service")
	}
	return service
}

func standardSyncService(t *testing.T, esController *endpointSliceController, namespace, serviceName string) {
	t.Helper()
	createService(t, esController, namespace, serviceName)

	err := esController.syncService(fmt.Sprintf("%s/%s", namespace, serviceName))
	if err != nil {
		t.Error("Expected no error syncing service")
	}
}
