// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

//go:build !plan9

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"testing"

	"github.com/google/go-cmp/cmp"
	"go.uber.org/zap"
	corev1 "k8s.io/api/core/v1"
	discoveryv1 "k8s.io/api/discovery/v1"
	networkingv1 "k8s.io/api/networking/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/types"
	"k8s.io/apimachinery/pkg/util/intstr"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/client/fake"
	operatorutils "tailscale.com/k8s-operator"
	tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
	"tailscale.com/kube/kubetypes"
	"tailscale.com/tstest"
	"tailscale.com/types/ptr"
)

func TestDNSRecordsReconciler(t *testing.T) {
	// Preconfigure a cluster with a DNSConfig
	dnsConfig := &tsapi.DNSConfig{
		ObjectMeta: metav1.ObjectMeta{
			Name: "test",
		},
		TypeMeta: metav1.TypeMeta{Kind: "DNSConfig"},
		Spec: tsapi.DNSConfigSpec{
			Nameserver: &tsapi.Nameserver{},
		}}
	ing := &networkingv1.Ingress{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "ts-ingress",
			Namespace: "test",
		},
		Spec: networkingv1.IngressSpec{
			IngressClassName: ptr.To("tailscale"),
		},
		Status: networkingv1.IngressStatus{
			LoadBalancer: networkingv1.IngressLoadBalancerStatus{
				Ingress: []networkingv1.IngressLoadBalancerIngress{{
					Hostname: "cluster.ingress.ts.net"}},
			},
		},
	}
	cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "dnsrecords", Namespace: "tailscale"}}
	fc := fake.NewClientBuilder().
		WithScheme(tsapi.GlobalScheme).
		WithObjects(cm).
		WithObjects(dnsConfig).
		WithObjects(ing).
		WithStatusSubresource(dnsConfig, ing).
		Build()
	zl, err := zap.NewDevelopment()
	if err != nil {
		t.Fatal(err)
	}
	cl := tstest.NewClock(tstest.ClockOpts{})
	// Set the ready condition of the DNSConfig
	mustUpdateStatus(t, fc, "", "test", func(c *tsapi.DNSConfig) {
		operatorutils.SetDNSConfigCondition(c, tsapi.NameserverReady, metav1.ConditionTrue, reasonNameserverCreated, reasonNameserverCreated, 0, cl, zl.Sugar())
	})
	dnsRR := &dnsRecordsReconciler{
		Client:      fc,
		logger:      zl.Sugar(),
		tsNamespace: "tailscale",
	}

	// 1. DNS record is created for an egress proxy configured via
	// tailscale.com/tailnet-fqdn annotation
	egressSvcFQDN := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:        "egress-fqdn",
			Namespace:   "test",
			Annotations: map[string]string{"tailscale.com/tailnet-fqdn": "foo.bar.ts.net"},
		},
		Spec: corev1.ServiceSpec{
			ExternalName: "unused",
			Type:         corev1.ServiceTypeExternalName,
		},
	}
	headlessForEgressSvcFQDN := headlessSvcForParent(egressSvcFQDN, "svc") // create the proxy headless Service
	ep := endpointSliceForService(headlessForEgressSvcFQDN, "10.9.8.7", discoveryv1.AddressTypeIPv4)
	epv6 := endpointSliceForService(headlessForEgressSvcFQDN, "2600:1900:4011:161:0:d:0:d", discoveryv1.AddressTypeIPv6)

	mustCreate(t, fc, egressSvcFQDN)
	mustCreate(t, fc, headlessForEgressSvcFQDN)
	mustCreate(t, fc, ep)
	mustCreate(t, fc, epv6)
	expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service
	// ConfigMap should now have a record for foo.bar.ts.net -> 10.8.8.7
	wantHosts := map[string][]string{"foo.bar.ts.net": {"10.9.8.7"}}
	wantHostsIPv6 := map[string][]string{"foo.bar.ts.net": {"2600:1900:4011:161:0:d:0:d"}}
	expectHostsRecordsWithIPv6(t, fc, wantHosts, wantHostsIPv6)

	// 2. DNS record is updated if tailscale.com/tailnet-fqdn annotation's
	// value changes
	mustUpdate(t, fc, "test", "egress-fqdn", func(svc *corev1.Service) {
		svc.Annotations["tailscale.com/tailnet-fqdn"] = "baz.bar.ts.net"
	})
	expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service
	wantHosts = map[string][]string{"baz.bar.ts.net": {"10.9.8.7"}}
	expectHostsRecords(t, fc, wantHosts)

	// 3. DNS record is updated if the IP address of the proxy Pod changes.
	ep = endpointSliceForService(headlessForEgressSvcFQDN, "10.6.5.4", discoveryv1.AddressTypeIPv4)
	mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) {
		ep.Endpoints[0].Addresses = []string{"10.6.5.4"}
	})
	expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service
	wantHosts = map[string][]string{"baz.bar.ts.net": {"10.6.5.4"}}
	expectHostsRecords(t, fc, wantHosts)

	// 4. DNS record is created for an ingress proxy configured via Ingress
	headlessForIngress := headlessSvcForParent(ing, "ingress")
	ep = endpointSliceForService(headlessForIngress, "10.9.8.7", discoveryv1.AddressTypeIPv4)
	mustCreate(t, fc, headlessForIngress)
	mustCreate(t, fc, ep)
	expectReconciled(t, dnsRR, "tailscale", "ts-ingress") // dns-records-reconciler should reconcile the headless Service
	wantHosts["cluster.ingress.ts.net"] = []string{"10.9.8.7"}
	expectHostsRecords(t, fc, wantHosts)

	// 5. DNS records are updated if Ingress's MagicDNS name changes (i.e users changed spec.tls.hosts[0])
	t.Log("test case 5")
	mustUpdateStatus(t, fc, "test", "ts-ingress", func(ing *networkingv1.Ingress) {
		ing.Status.LoadBalancer.Ingress[0].Hostname = "another.ingress.ts.net"
	})
	expectReconciled(t, dnsRR, "tailscale", "ts-ingress") // dns-records-reconciler should reconcile the headless Service
	delete(wantHosts, "cluster.ingress.ts.net")
	wantHosts["another.ingress.ts.net"] = []string{"10.9.8.7"}
	expectHostsRecords(t, fc, wantHosts)

	// 6. DNS records are updated if Ingress proxy's Pod IP changes
	mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) {
		ep.Endpoints[0].Addresses = []string{"7.8.9.10"}
	})
	expectReconciled(t, dnsRR, "tailscale", "ts-ingress")
	wantHosts["another.ingress.ts.net"] = []string{"7.8.9.10"}
	expectHostsRecords(t, fc, wantHosts)

	// 7. A not-ready Endpoint is removed from DNS config.
	mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) {
		ep.Endpoints[0].Conditions.Ready = ptr.To(false)
		ep.Endpoints = append(ep.Endpoints, discoveryv1.Endpoint{
			Addresses: []string{"1.2.3.4"},
		})
	})
	expectReconciled(t, dnsRR, "tailscale", "ts-ingress")
	wantHosts["another.ingress.ts.net"] = []string{"1.2.3.4"}
	expectHostsRecords(t, fc, wantHosts)

	// 8. DNS record is created for ProxyGroup egress using ClusterIP Service IP instead of Pod IPs
	t.Log("test case 8: ProxyGroup egress")

	// Create the parent ExternalName service with tailnet-fqdn annotation
	parentEgressSvc := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "external-service",
			Namespace: "default",
			Annotations: map[string]string{
				AnnotationTailnetTargetFQDN: "external-service.example.ts.net",
			},
		},
		Spec: corev1.ServiceSpec{
			Type:         corev1.ServiceTypeExternalName,
			ExternalName: "unused",
		},
	}
	mustCreate(t, fc, parentEgressSvc)

	proxyGroupEgressSvc := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "ts-proxygroup-egress-abcd1",
			Namespace: "tailscale",
			Labels: map[string]string{
				kubetypes.LabelManaged: "true",
				LabelParentName:        "external-service",
				LabelParentNamespace:   "default",
				LabelParentType:        "svc",
				labelProxyGroup:        "test-proxy-group",
				labelSvcType:           typeEgress,
			},
		},
		Spec: corev1.ServiceSpec{
			Type:      corev1.ServiceTypeClusterIP,
			ClusterIP: "10.0.100.50", // This IP should be used in DNS, not Pod IPs
			Ports: []corev1.ServicePort{{
				Port:       443,
				TargetPort: intstr.FromInt(10443), // Port mapping
			}},
		},
	}

	// Create EndpointSlice with Pod IPs (these should NOT be used in DNS records)
	proxyGroupEps := &discoveryv1.EndpointSlice{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "ts-proxygroup-egress-abcd1-ipv4",
			Namespace: "tailscale",
			Labels: map[string]string{
				discoveryv1.LabelServiceName: "ts-proxygroup-egress-abcd1",
				kubetypes.LabelManaged:       "true",
				LabelParentName:              "external-service",
				LabelParentNamespace:         "default",
				LabelParentType:              "svc",
				labelProxyGroup:              "test-proxy-group",
				labelSvcType:                 typeEgress,
			},
		},
		AddressType: discoveryv1.AddressTypeIPv4,
		Endpoints: []discoveryv1.Endpoint{{
			Addresses: []string{"10.1.0.100", "10.1.0.101", "10.1.0.102"}, // Pod IPs that should NOT be used
			Conditions: discoveryv1.EndpointConditions{
				Ready:       ptr.To(true),
				Serving:     ptr.To(true),
				Terminating: ptr.To(false),
			},
		}},
		Ports: []discoveryv1.EndpointPort{{
			Port: ptr.To(int32(10443)),
		}},
	}

	mustCreate(t, fc, proxyGroupEgressSvc)
	mustCreate(t, fc, proxyGroupEps)
	expectReconciled(t, dnsRR, "tailscale", "ts-proxygroup-egress-abcd1")

	// Verify DNS record uses ClusterIP Service IP, not Pod IPs
	wantHosts["external-service.example.ts.net"] = []string{"10.0.100.50"}
	expectHostsRecords(t, fc, wantHosts)

	// 9. ProxyGroup egress DNS record updates when ClusterIP changes
	t.Log("test case 9: ProxyGroup egress ClusterIP change")
	mustUpdate(t, fc, "tailscale", "ts-proxygroup-egress-abcd1", func(svc *corev1.Service) {
		svc.Spec.ClusterIP = "10.0.100.51"
	})
	expectReconciled(t, dnsRR, "tailscale", "ts-proxygroup-egress-abcd1")
	wantHosts["external-service.example.ts.net"] = []string{"10.0.100.51"}
	expectHostsRecords(t, fc, wantHosts)

	// 10. Test ProxyGroup service deletion and DNS cleanup
	t.Log("test case 10: ProxyGroup egress service deletion")
	mustDeleteAll(t, fc, proxyGroupEgressSvc)
	expectReconciled(t, dnsRR, "tailscale", "ts-proxygroup-egress-abcd1")
	delete(wantHosts, "external-service.example.ts.net")
	expectHostsRecords(t, fc, wantHosts)
}

func TestDNSRecordsReconcilerErrorCases(t *testing.T) {
	zl, err := zap.NewDevelopment()
	if err != nil {
		t.Fatal(err)
	}

	dnsRR := &dnsRecordsReconciler{
		logger: zl.Sugar(),
	}

	testSvc := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{Name: "test"},
		Spec:       corev1.ServiceSpec{Type: corev1.ServiceTypeClusterIP},
	}

	// Test invalid IP format
	testSvc.Spec.ClusterIP = "invalid-ip"
	_, _, err = dnsRR.getClusterIPServiceIPs(testSvc, zl.Sugar())
	if err == nil {
		t.Error("expected error for invalid IP format")
	}

	// Test valid IP
	testSvc.Spec.ClusterIP = "10.0.100.50"
	ip4s, ip6s, err := dnsRR.getClusterIPServiceIPs(testSvc, zl.Sugar())
	if err != nil {
		t.Errorf("unexpected error for valid IP: %v", err)
	}
	if len(ip4s) != 1 || ip4s[0] != "10.0.100.50" {
		t.Errorf("expected IPv4 address 10.0.100.50, got %v", ip4s)
	}
	if len(ip6s) != 0 {
		t.Errorf("expected no IPv6 addresses, got %v", ip6s)
	}
}

func TestDNSRecordsReconcilerDualStack(t *testing.T) {
	// Test dual-stack (IPv4 and IPv6) scenarios
	zl, err := zap.NewDevelopment()
	if err != nil {
		t.Fatal(err)
	}

	// Preconfigure cluster with DNSConfig
	dnsCfg := &tsapi.DNSConfig{
		ObjectMeta: metav1.ObjectMeta{Name: "test"},
		TypeMeta:   metav1.TypeMeta{Kind: "DNSConfig"},
		Spec:       tsapi.DNSConfigSpec{Nameserver: &tsapi.Nameserver{}},
	}
	dnsCfg.Status.Conditions = append(dnsCfg.Status.Conditions, metav1.Condition{
		Type:   string(tsapi.NameserverReady),
		Status: metav1.ConditionTrue,
	})

	// Create dual-stack ingress
	ing := &networkingv1.Ingress{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "dual-stack-ingress",
			Namespace: "test",
		},
		Spec: networkingv1.IngressSpec{
			IngressClassName: ptr.To("tailscale"),
		},
		Status: networkingv1.IngressStatus{
			LoadBalancer: networkingv1.IngressLoadBalancerStatus{
				Ingress: []networkingv1.IngressLoadBalancerIngress{
					{Hostname: "dual-stack.example.ts.net"},
				},
			},
		},
	}

	headlessSvc := headlessSvcForParent(ing, "ingress")
	headlessSvc.Name = "ts-dual-stack-ingress"
	headlessSvc.SetLabels(map[string]string{
		kubetypes.LabelManaged: "true",
		LabelParentName:        "dual-stack-ingress",
		LabelParentNamespace:   "test",
		LabelParentType:        "ingress",
	})

	// Create both IPv4 and IPv6 endpoints
	epv4 := endpointSliceForService(headlessSvc, "10.1.2.3", discoveryv1.AddressTypeIPv4)
	epv6 := endpointSliceForService(headlessSvc, "2001:db8::1", discoveryv1.AddressTypeIPv6)

	dnsRRDualStack := &dnsRecordsReconciler{
		tsNamespace: "tailscale",
		logger:      zl.Sugar(),
	}

	// Create the dnsrecords ConfigMap
	cm := &corev1.ConfigMap{
		ObjectMeta: metav1.ObjectMeta{
			Name:      operatorutils.DNSRecordsCMName,
			Namespace: "tailscale",
		},
	}

	fc := fake.NewClientBuilder().
		WithScheme(tsapi.GlobalScheme).
		WithObjects(dnsCfg, ing, headlessSvc, epv4, epv6, cm).
		WithStatusSubresource(dnsCfg).
		Build()

	dnsRRDualStack.Client = fc

	// Test dual-stack service records
	expectReconciled(t, dnsRRDualStack, "tailscale", "ts-dual-stack-ingress")

	wantIPv4 := map[string][]string{"dual-stack.example.ts.net": {"10.1.2.3"}}
	wantIPv6 := map[string][]string{"dual-stack.example.ts.net": {"2001:db8::1"}}
	expectHostsRecordsWithIPv6(t, fc, wantIPv4, wantIPv6)

	// Test ProxyGroup with dual-stack ClusterIPs
	// First create parent service
	parentEgressSvc := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "pg-service",
			Namespace: "tailscale",
			Annotations: map[string]string{
				AnnotationTailnetTargetFQDN: "pg-service.example.ts.net",
			},
		},
		Spec: corev1.ServiceSpec{
			Type:         corev1.ServiceTypeExternalName,
			ExternalName: "unused",
		},
	}

	proxyGroupSvc := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "ts-proxygroup-dualstack",
			Namespace: "tailscale",
			Labels: map[string]string{
				kubetypes.LabelManaged: "true",
				labelProxyGroup:        "test-pg",
				labelSvcType:           typeEgress,
				LabelParentName:        "pg-service",
				LabelParentNamespace:   "tailscale",
				LabelParentType:        "svc",
			},
			Annotations: map[string]string{
				annotationTSMagicDNSName: "pg-service.example.ts.net",
			},
		},
		Spec: corev1.ServiceSpec{
			Type:       corev1.ServiceTypeClusterIP,
			ClusterIP:  "10.96.0.100",
			ClusterIPs: []string{"10.96.0.100", "2001:db8::100"},
		},
	}

	mustCreate(t, fc, parentEgressSvc)
	mustCreate(t, fc, proxyGroupSvc)
	expectReconciled(t, dnsRRDualStack, "tailscale", "ts-proxygroup-dualstack")

	wantIPv4["pg-service.example.ts.net"] = []string{"10.96.0.100"}
	wantIPv6["pg-service.example.ts.net"] = []string{"2001:db8::100"}
	expectHostsRecordsWithIPv6(t, fc, wantIPv4, wantIPv6)
}

func headlessSvcForParent(o client.Object, typ string) *corev1.Service {
	return &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      o.GetName(),
			Namespace: "tailscale",
			Labels: map[string]string{
				kubetypes.LabelManaged: "true",
				LabelParentName:        o.GetName(),
				LabelParentNamespace:   o.GetNamespace(),
				LabelParentType:        typ,
			},
		},
		Spec: corev1.ServiceSpec{
			ClusterIP: "None",
			Type:      corev1.ServiceTypeClusterIP,
			Selector:  map[string]string{"foo": "bar"},
		},
	}
}

func endpointSliceForService(svc *corev1.Service, ip string, fam discoveryv1.AddressType) *discoveryv1.EndpointSlice {
	return &discoveryv1.EndpointSlice{
		ObjectMeta: metav1.ObjectMeta{
			Name:      fmt.Sprintf("%s-%s", svc.Name, string(fam)),
			Namespace: svc.Namespace,
			Labels:    map[string]string{discoveryv1.LabelServiceName: svc.Name},
		},
		AddressType: fam,
		Endpoints: []discoveryv1.Endpoint{{
			Addresses: []string{ip},
			Conditions: discoveryv1.EndpointConditions{
				Ready:       ptr.To(true),
				Serving:     ptr.To(true),
				Terminating: ptr.To(false),
			},
		}},
	}
}

func expectHostsRecords(t *testing.T, cl client.Client, wantsHosts map[string][]string) {
	t.Helper()
	cm := new(corev1.ConfigMap)
	if err := cl.Get(context.Background(), types.NamespacedName{Name: "dnsrecords", Namespace: "tailscale"}, cm); err != nil {
		t.Fatalf("getting dnsconfig ConfigMap: %v", err)
	}
	if cm.Data == nil {
		t.Fatal("dnsconfig ConfigMap has no data")
	}
	dnsConfigString, ok := cm.Data[operatorutils.DNSRecordsCMKey]
	if !ok {
		t.Fatal("dnsconfig ConfigMap does not contain dnsconfig")
	}
	dnsConfig := &operatorutils.Records{}
	if err := json.Unmarshal([]byte(dnsConfigString), dnsConfig); err != nil {
		t.Fatalf("unmarshaling dnsconfig: %v", err)
	}
	if diff := cmp.Diff(dnsConfig.IP4, wantsHosts); diff != "" {
		t.Fatalf("unexpected dns config (-got +want):\n%s", diff)
	}
}

func expectHostsRecordsWithIPv6(t *testing.T, cl client.Client, wantsHostsIPv4, wantsHostsIPv6 map[string][]string) {
	t.Helper()
	cm := new(corev1.ConfigMap)
	if err := cl.Get(context.Background(), types.NamespacedName{Name: "dnsrecords", Namespace: "tailscale"}, cm); err != nil {
		t.Fatalf("getting dnsconfig ConfigMap: %v", err)
	}
	if cm.Data == nil {
		t.Fatal("dnsconfig ConfigMap has no data")
	}
	dnsConfigString, ok := cm.Data[operatorutils.DNSRecordsCMKey]
	if !ok {
		t.Fatal("dnsconfig ConfigMap does not contain dnsconfig")
	}
	dnsConfig := &operatorutils.Records{}
	if err := json.Unmarshal([]byte(dnsConfigString), dnsConfig); err != nil {
		t.Fatalf("unmarshaling dnsconfig: %v", err)
	}
	if diff := cmp.Diff(dnsConfig.IP4, wantsHostsIPv4); diff != "" {
		t.Fatalf("unexpected IPv4 dns config (-got +want):\n%s", diff)
	}
	if diff := cmp.Diff(dnsConfig.IP6, wantsHostsIPv6); diff != "" {
		t.Fatalf("unexpected IPv6 dns config (-got +want):\n%s", diff)
	}
}
