package task

import (
	"context"
	"testing"

	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
	"github.com/crossplane/crossplane-runtime/pkg/meta"
	"github.com/stretchr/testify/require"
	"github.com/tidbcloud/infra-provider/pkg/consts"
	"github.com/tidbcloud/infra-provider/pkg/k8s"
	crossplaneutil "github.com/tidbcloud/infra-provider/pkg/util/crossplane"
	route53v1beta1 "github.com/upbound/provider-aws/apis/route53/v1beta1"
	azurenetv1beta1 "github.com/upbound/provider-azure/apis/network/v1beta1"
	gcpdnsv1beta1 "github.com/upbound/provider-gcp/apis/dns/v1beta1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/client/fake"
)

const route53Paused = "true"

type invalidDeepCopyObject struct {
	metav1.ObjectMeta
}

func (*invalidDeepCopyObject) GetObjectKind() schema.ObjectKind {
	return schema.EmptyObjectKind
}

func (*invalidDeepCopyObject) DeepCopyObject() runtime.Object {
	return &runtime.Unknown{}
}

func TestCopyDNSObjectRejectsInvalidDeepCopy(t *testing.T) {
	copied, err := copyDNSObject(&invalidDeepCopyObject{
		ObjectMeta: metav1.ObjectMeta{Name: "invalid"},
	})

	require.Nil(t, copied)
	require.ErrorContains(t, err, "does not implement client.Object")
}

func TestReconcileDNSObjectsPausesStableRoute53Objects(t *testing.T) {
	ctx := context.Background()
	zone := newStableRoute53Zone()
	record := newStableRoute53Record()
	cli := newRoute53PauseTestClient(t, zone, record)
	objects := []client.Object{
		&route53v1beta1.Zone{ObjectMeta: metav1.ObjectMeta{Name: zone.Name}},
		&route53v1beta1.Record{ObjectMeta: metav1.ObjectMeta{Name: record.Name}},
	}

	// The first reconcile applies the desired objects; the second observes
	// stable resources and pauses the Zone and Records.
	result := reconcileDNSObjects(ctx, cli, objects)
	require.True(t, result.IsDoneState())
	require.Error(t, result.RawError())
	result = reconcileDNSObjects(ctx, cli, objects)
	require.True(t, result.IsDoneState())
	require.NoError(t, result.RawError())

	current := &route53v1beta1.Zone{}
	require.NoError(t, cli.Get(ctx, client.ObjectKey{Name: zone.Name}, current))
	require.Equal(t, route53Paused, current.Annotations[meta.AnnotationKeyReconciliationPaused])

	currentRecord := &route53v1beta1.Record{}
	require.NoError(t, cli.Get(ctx, client.ObjectKey{Name: record.Name}, currentRecord))
	require.Equal(t, route53Paused, currentRecord.Annotations[meta.AnnotationKeyReconciliationPaused])
}

func TestRoute53ObjectReady(t *testing.T) {
	zoneWithoutExternalName := newStableRoute53Zone()
	delete(zoneWithoutExternalName.Annotations, meta.AnnotationKeyExternalName)

	tests := []struct {
		name  string
		obj   client.Object
		ready bool
	}{
		{
			name:  "stable zone",
			obj:   newStableRoute53Zone(),
			ready: true,
		},
		{
			name: "zone has no external name",
			obj:  zoneWithoutExternalName,
		},
		{
			name: "zone is not ready",
			obj:  &route53v1beta1.Zone{ObjectMeta: metav1.ObjectMeta{Name: "dns-endpoint"}},
		},
		{
			name:  "stable record",
			obj:   newStableRoute53Record(),
			ready: true,
		},
		{
			name: "record is not ready",
			obj:  &route53v1beta1.Record{ObjectMeta: metav1.ObjectMeta{Name: "dns-endpoint-all"}},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			require.Equal(t, tt.ready, route53ObjectReady(tt.obj))
		})
	}
}

func TestRoute53PauseApplyAndRollbackCompatibility(t *testing.T) {
	ctx := context.Background()
	zone := newStableRoute53Zone()
	zone.Finalizers = []string{"finalizer.managedresource.crossplane.io"}
	record := newStableRoute53Record()
	cli := newRoute53PauseTestClient(t, zone, record)
	desiredZone := &route53v1beta1.Zone{ObjectMeta: metav1.ObjectMeta{Name: zone.Name}}
	desiredRecord := &route53v1beta1.Record{ObjectMeta: metav1.ObjectMeta{Name: record.Name}}
	objects := []client.Object{desiredZone, desiredRecord}

	result := reconcileDNSObjects(ctx, cli, objects)
	require.True(t, result.IsDoneState())
	require.Error(t, result.RawError())
	require.Empty(t, desiredZone.Annotations)
	require.Empty(t, desiredZone.Finalizers)
	require.Empty(t, desiredZone.Status.Conditions)

	result = reconcileDNSObjects(ctx, cli, objects)
	require.True(t, result.IsDoneState())
	require.NoError(t, result.RawError())

	current := &route53v1beta1.Zone{}
	require.NoError(t, cli.Get(ctx, client.ObjectKey{Name: zone.Name}, current))
	require.True(t, k8s.IsCrossplaneCRPaused(current))
	lastApplied := current.Annotations[consts.LastAppliedConfigAnnotation]
	require.Contains(t, lastApplied, meta.AnnotationKeyReconciliationPaused)
	require.NotContains(t, lastApplied, meta.AnnotationKeyExternalName)
	require.NotContains(t, lastApplied, "finalizer.managedresource.crossplane.io")
	require.NotContains(t, lastApplied, `"conditions"`)
	require.NotContains(t, lastApplied, `"finalizers"`)
	require.NotContains(t, lastApplied, `"uid"`)
	require.NotContains(t, lastApplied, `"resourceVersion"`)

	currentRecord := &route53v1beta1.Record{}
	require.NoError(t, cli.Get(ctx, client.ObjectKey{Name: record.Name}, currentRecord))
	require.True(t, k8s.IsCrossplaneCRPaused(currentRecord))

	// The old version applies the objects without the pause annotation, which
	// removes the managed pause from the three-way last-applied configuration.
	require.NoError(t, k8s.Apply(ctx, cli, &route53v1beta1.Zone{
		ObjectMeta: metav1.ObjectMeta{Name: zone.Name},
	}))
	require.NoError(t, k8s.Apply(ctx, cli, &route53v1beta1.Record{
		ObjectMeta: metav1.ObjectMeta{Name: record.Name},
	}))
	require.NoError(t, cli.Get(ctx, client.ObjectKey{Name: zone.Name}, current))
	require.False(t, k8s.IsCrossplaneCRPaused(current))
	require.NoError(t, cli.Get(ctx, client.ObjectKey{Name: record.Name}, currentRecord))
	require.False(t, k8s.IsCrossplaneCRPaused(currentRecord))
	require.Equal(t, meta.GetExternalName(zone), meta.GetExternalName(current))
	require.Equal(t, zone.Finalizers, current.Finalizers)
	require.True(t, crossplaneutil.IsReadyAndSynced(current.Status.Conditions))
}

func TestPausedRoute53ObjectsAreNotAppliedAgain(t *testing.T) {
	ctx := context.Background()
	zone := newStableRoute53Zone()
	zone.Annotations[meta.AnnotationKeyReconciliationPaused] = route53Paused
	record := newStableRoute53Record()
	record.Annotations = map[string]string{meta.AnnotationKeyReconciliationPaused: route53Paused}
	oldRecordValue := "old.example.com"
	record.Spec.ForProvider.Records = []*string{&oldRecordValue}
	cli := newRoute53PauseTestClient(t, zone, record)

	newRecordValue := "new.example.com"
	desiredZone := &route53v1beta1.Zone{
		ObjectMeta: metav1.ObjectMeta{
			Name:        zone.Name,
			Annotations: map[string]string{"should-not-be-applied": "true"},
		},
	}
	desiredRecord := &route53v1beta1.Record{
		ObjectMeta: metav1.ObjectMeta{Name: record.Name},
		Spec: route53v1beta1.RecordSpec{
			ForProvider: route53v1beta1.RecordParameters{
				Records: []*string{&newRecordValue},
			},
		},
	}
	objects := []client.Object{desiredZone, desiredRecord}

	result := reconcileDNSObjects(ctx, cli, objects)
	require.True(t, result.IsDoneState())
	require.NoError(t, result.RawError())

	currentZone := &route53v1beta1.Zone{}
	require.NoError(t, cli.Get(ctx, client.ObjectKey{Name: zone.Name}, currentZone))
	require.True(t, k8s.IsCrossplaneCRPaused(currentZone))
	require.Empty(t, currentZone.Annotations["should-not-be-applied"])

	currentRecord := &route53v1beta1.Record{}
	require.NoError(t, cli.Get(ctx, client.ObjectKey{Name: record.Name}, currentRecord))
	require.True(t, k8s.IsCrossplaneCRPaused(currentRecord))
	require.Equal(t, []*string{&oldRecordValue}, currentRecord.Spec.ForProvider.Records)
}

func TestReconcileDNSObjectsUsesDefaultApplyForOtherProviders(t *testing.T) {
	scheme := runtime.NewScheme()
	require.NoError(t, gcpdnsv1beta1.AddToScheme(scheme))
	require.NoError(t, azurenetv1beta1.AddToScheme(scheme))

	tests := []struct {
		name      string
		newObject func() client.Object
	}{
		{
			name: "gcp managed zone",
			newObject: func() client.Object {
				return &gcpdnsv1beta1.ManagedZone{
					ObjectMeta: metav1.ObjectMeta{Name: "dns-gcp-endpoint"},
				}
			},
		},
		{
			name: "azure private dns zone",
			newObject: func() client.Object {
				return &azurenetv1beta1.PrivateDNSZone{
					ObjectMeta: metav1.ObjectMeta{Name: "dns-azure-endpoint"},
				}
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			cli := fake.NewClientBuilder().WithScheme(scheme).Build()
			desired := tt.newObject()

			result := reconcileDNSObjects(context.Background(), cli, []client.Object{desired})
			require.True(t, result.IsDoneState())
			require.NoError(t, result.RawError())

			current := tt.newObject()
			require.NoError(t, cli.Get(context.Background(), client.ObjectKeyFromObject(desired), current))
			require.NotEmpty(t, current.GetAnnotations()[consts.LastAppliedConfigAnnotation])
		})
	}
}

func newStableRoute53Zone() *route53v1beta1.Zone {
	zone := &route53v1beta1.Zone{
		ObjectMeta: metav1.ObjectMeta{
			Name: "dns-endpoint",
			Annotations: map[string]string{
				meta.AnnotationKeyExternalName: "Z0123456789",
			},
		},
	}
	zone.Status.SetConditions(xpv1.Available(), xpv1.ReconcileSuccess())
	return zone
}

func newStableRoute53Record() *route53v1beta1.Record {
	record := &route53v1beta1.Record{ObjectMeta: metav1.ObjectMeta{Name: "dns-endpoint-all"}}
	record.Status.SetConditions(xpv1.Available(), xpv1.ReconcileSuccess())
	return record
}

func newRoute53PauseTestClient(t *testing.T, objects ...client.Object) client.Client {
	t.Helper()
	scheme := runtime.NewScheme()
	require.NoError(t, route53v1beta1.AddToScheme(scheme))
	return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build()
}
