package gatewaycontroller

import (
	"context"
	"testing"

	monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1"
	"github.com/stretchr/testify/require"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/resource"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	"sigs.k8s.io/controller-runtime/pkg/client/fake"

	gatewayv1alpha1 "github.com/tidbcloud/aws-shared-provider/apis/gateway/v1alpha1"
)

func TestBuildNodeAffinity(t *testing.T) {
	t.Parallel()

	t.Run("aws uses default zones", func(t *testing.T) {
		t.Parallel()

		reconciler := &GatewayReconciler{
			Config: &Config{
				CloudProvider: "aws",
			},
		}

		affinity := reconciler.buildNodeAffinity(newTestGateway(ingressClassPrivateLinkZR), "v1")
		req, ok := findNodeSelectorRequirement(affinity, "serverless.tidbcloud.com/zone")
		require.True(t, ok)
		require.Equal(t, []string{"primary", "standby"}, req.Values)
	})

	t.Run("alicloud uses configured zones for matching ingress class", func(t *testing.T) {
		t.Parallel()

		reconciler := &GatewayReconciler{
			Config: &Config{
				CloudProvider: "alicloud",
				ZoneAffinity: map[string][]string{
					ingressClassPublic: {"us-east-1a"},
				},
			},
		}

		affinity := reconciler.buildNodeAffinity(newTestGateway(ingressClassPublic), "v1")
		req, ok := findNodeSelectorRequirement(affinity, corev1.LabelTopologyZone)
		require.True(t, ok)
		require.Equal(t, []string{"us-east-1a"}, req.Values)
	})

	t.Run("alicloud skips zone selector for unconfigured ingress class", func(t *testing.T) {
		t.Parallel()

		reconciler := &GatewayReconciler{
			Config: &Config{
				CloudProvider: "alicloud",
				ZoneAffinity: map[string][]string{
					ingressClassPublic: {"us-east-1a"},
				},
			},
		}

		affinity := reconciler.buildNodeAffinity(newTestGateway(ingressClassPrivateLink), "v1")
		_, ok := findNodeSelectorRequirement(affinity, corev1.LabelTopologyZone)
		require.False(t, ok)
	})
}

func TestBuildTopologySpreadConstraints(t *testing.T) {
	t.Parallel()

	assertSpread := func(t *testing.T, constraints []corev1.TopologySpreadConstraint) {
		t.Helper()
		require.Len(t, constraints, 1)
		require.Equal(t, corev1.LabelTopologyZone, constraints[0].TopologyKey)
		require.Equal(t, int32(1), constraints[0].MaxSkew)
		require.Equal(t, corev1.DoNotSchedule, constraints[0].WhenUnsatisfiable)
		require.Equal(t, map[string]string{"version": "v1"}, constraints[0].LabelSelector.MatchLabels)
	}

	t.Run("aws privatelink-zr spreads across zones", func(t *testing.T) {
		t.Parallel()
		reconciler := &GatewayReconciler{Config: &Config{CloudProvider: "aws"}}
		assertSpread(t, reconciler.buildTopologySpreadConstraints(newTestGateway(ingressClassPrivateLinkZR), "v1"))
	})

	t.Run("aws privatelink is single-zone, no constraint", func(t *testing.T) {
		t.Parallel()
		reconciler := &GatewayReconciler{Config: &Config{CloudProvider: "aws"}}
		require.Nil(t, reconciler.buildTopologySpreadConstraints(newTestGateway(ingressClassPrivateLink), "v1"))
	})

	t.Run("aws public is single-zone, no constraint", func(t *testing.T) {
		t.Parallel()
		reconciler := &GatewayReconciler{Config: &Config{CloudProvider: "aws"}}
		require.Nil(t, reconciler.buildTopologySpreadConstraints(newTestGateway(ingressClassPublic), "v1"))
	})

	t.Run("alicloud default privatelink spreads across zones", func(t *testing.T) {
		t.Parallel()
		reconciler := &GatewayReconciler{Config: &Config{CloudProvider: "alicloud"}}
		assertSpread(t, reconciler.buildTopologySpreadConstraints(newTestGateway(ingressClassPrivateLink), "v1"))
	})

	t.Run("alicloud with configured zone affinity has no constraint", func(t *testing.T) {
		t.Parallel()
		reconciler := &GatewayReconciler{
			Config: &Config{
				CloudProvider: "alicloud",
				ZoneAffinity: map[string][]string{
					ingressClassPrivateLink: {"cn-hangzhou-h"},
				},
			},
		}
		require.Nil(t, reconciler.buildTopologySpreadConstraints(newTestGateway(ingressClassPrivateLink), "v1"))
	})
}

func TestCreateMonitorResourcesUpdatesScrapeInterval(t *testing.T) {
	t.Parallel()

	for _, ingressClass := range []string{ingressClassPublic, ingressClassPrivateLink, ingressClassPrivateLinkZR} {
		t.Run(ingressClass, func(t *testing.T) {
			t.Parallel()

			scheme := runtime.NewScheme()
			require.NoError(t, corev1.AddToScheme(scheme))
			require.NoError(t, gatewayv1alpha1.AddToScheme(scheme))
			require.NoError(t, monitoringv1.AddToScheme(scheme))

			gateway := newTestGateway(ingressClass)
			gateway.Name = ingressClass
			serviceName := gateway.Name + "-metrics"
			service := &corev1.Service{
				ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: gateway.Namespace},
				Spec: corev1.ServiceSpec{
					Selector: map[string]string{"control-plane": "gateway-controller"},
				},
			}
			serviceMonitor := &monitoringv1.ServiceMonitor{
				ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: gateway.Namespace},
				Spec: monitoringv1.ServiceMonitorSpec{
					Endpoints: []monitoringv1.Endpoint{{Port: "metrics", Path: "/metrics"}},
				},
			}
			client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(service, serviceMonitor).Build()
			reconciler := &GatewayReconciler{Client: client, Scheme: scheme}

			err := reconciler.createMonitorResources(context.Background(), gateway)
			require.NoError(t, err)

			updated := &monitoringv1.ServiceMonitor{}
			require.NoError(t, client.Get(context.Background(), types.NamespacedName{
				Name: serviceName, Namespace: gateway.Namespace,
			}, updated))
			require.Len(t, updated.Spec.Endpoints, 1)
			require.Equal(t, gatewayMetricsInterval, updated.Spec.Endpoints[0].Interval)
		})
	}
}

func TestBuildGatewayResourceRequirements(t *testing.T) {
	t.Parallel()

	t.Run("uses configured CPU request ratio", func(t *testing.T) {
		t.Parallel()

		requirements := buildGatewayResourceRequirements(&gatewayv1alpha1.Gateway{
			Spec: gatewayv1alpha1.GatewaySpec{
				CpuLimit:        "1",
				CpuRequestRatio: 0.75,
				MemoryLimit:     "4Gi",
			},
		})

		requireQuantityEqual(t, "750m", requirements.Requests[corev1.ResourceCPU])
		requireQuantityEqual(t, "1", requirements.Limits[corev1.ResourceCPU])
		requireQuantityEqual(t, "4Gi", requirements.Requests[corev1.ResourceMemory])
		requireQuantityEqual(t, "4Gi", requirements.Limits[corev1.ResourceMemory])
	})

	t.Run("defaults CPU request to CPU limit", func(t *testing.T) {
		t.Parallel()

		requirements := buildGatewayResourceRequirements(&gatewayv1alpha1.Gateway{
			Spec: gatewayv1alpha1.GatewaySpec{
				CpuLimit:    "4",
				MemoryLimit: "8Gi",
			},
		})

		requireQuantityEqual(t, "4", requirements.Requests[corev1.ResourceCPU])
		requireQuantityEqual(t, "4", requirements.Limits[corev1.ResourceCPU])
	})
}

func TestGatewayResourceRequirementsEqualComparesQuantitiesByValue(t *testing.T) {
	t.Parallel()

	requirements := buildGatewayResourceRequirements(&gatewayv1alpha1.Gateway{
		Spec: gatewayv1alpha1.GatewaySpec{
			CpuLimit:        "1",
			CpuRequestRatio: 0.75,
			MemoryLimit:     "4Gi",
		},
	})
	equivalentRequirements := corev1.ResourceRequirements{
		Requests: corev1.ResourceList{
			corev1.ResourceCPU:    resource.MustParse("0.75"),
			corev1.ResourceMemory: resource.MustParse("4096Mi"),
		},
		Limits: corev1.ResourceList{
			corev1.ResourceCPU:    resource.MustParse("1000m"),
			corev1.ResourceMemory: resource.MustParse("4Gi"),
		},
	}

	require.True(t, gatewayResourceRequirementsEqual(equivalentRequirements, requirements))
}

func requireQuantityEqual(t *testing.T, expected string, actual resource.Quantity) {
	t.Helper()

	expectedQuantity := resource.MustParse(expected)
	require.Zero(t, expectedQuantity.Cmp(actual))
}

func newTestGateway(ingressClass string) *gatewayv1alpha1.Gateway {
	return &gatewayv1alpha1.Gateway{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "test-gateway",
			Namespace: "default",
		},
		Spec: gatewayv1alpha1.GatewaySpec{
			IngressClass: ingressClass,
		},
	}
}

func findNodeSelectorRequirement(affinity *corev1.Affinity, key string) (corev1.NodeSelectorRequirement, bool) {
	if affinity == nil || affinity.NodeAffinity == nil || affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution == nil {
		return corev1.NodeSelectorRequirement{}, false
	}

	for _, term := range affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms {
		for _, expr := range term.MatchExpressions {
			if expr.Key == key {
				return expr, true
			}
		}
	}

	return corev1.NodeSelectorRequirement{}, false
}
