/*
Copyright © The ESO Authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package secretstore

import (
	"context"
	"time"

	corev1 "k8s.io/api/core/v1"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/types"

	esapi "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

type testCase struct {
	store  esapi.GenericStore
	ps     *esv1alpha1.PushSecret
	assert func()
}

const (
	defaultStoreName       = "default-store"
	defaultControllerClass = "test-ctrl"
)

var _ = Describe("SecretStore Controller", func() {
	var test *testCase

	BeforeEach(func() {
		test = makeDefaultTestcase()
	})

	Context("Reconcile Logic", func() {
		AfterEach(func() {
			Expect(k8sClient.Delete(context.Background(), test.store)).ToNot(HaveOccurred())
		})

		// an invalid provider config should be reflected
		// in the store status condition
		invalidProvider := func(tc *testCase) {
			tc.assert = func() {
				Eventually(func() bool {
					ss := tc.store.Copy()
					err := k8sClient.Get(context.Background(), types.NamespacedName{
						Name:      defaultStoreName,
						Namespace: ss.GetObjectMeta().Namespace,
					}, ss)
					if err != nil {
						return false
					}
					status := ss.GetStatus()
					if len(status.Conditions) != 1 {
						return false
					}
					return status.Conditions[0].Reason == esapi.ReasonInvalidProviderConfig &&
						hasEvent(tc.store.GetTypeMeta().Kind, ss.GetName(), esapi.ReasonInvalidProviderConfig)
				}).
					WithTimeout(time.Second * 10).
					WithPolling(time.Second).
					Should(BeTrue())
			}
		}

		// if controllerClass does not match the controller
		// should not touch this store
		ignoreControllerClass := func(tc *testCase) {
			spc := tc.store.GetSpec()
			spc.Controller = "something-else"
			tc.assert = func() {
				Eventually(func() bool {
					ss := tc.store.Copy()
					err := k8sClient.Get(context.Background(), types.NamespacedName{
						Name:      defaultStoreName,
						Namespace: ss.GetObjectMeta().Namespace,
					}, ss)
					if err != nil {
						return false
					}
					return ss.GetObjectMeta().GetResourceVersion() != "" &&
						ss.GetSpec().Controller == "something-else" &&
						len(ss.GetStatus().Conditions) == 0
				}).
					WithTimeout(time.Second*10).
					WithPolling(time.Millisecond*250).
					Should(BeTrue(), "cache should reflect the new store with no conditions")

				Consistently(func() bool {
					ss := tc.store.Copy()
					err := k8sClient.Get(context.Background(), types.NamespacedName{
						Name:      defaultStoreName,
						Namespace: ss.GetObjectMeta().Namespace,
					}, ss)
					if err != nil {
						return true
					}

					conditionLen := len(ss.GetStatus().Conditions) == 0
					if !conditionLen {
						GinkgoLogr.Info("store conditions is NOT empty but should have been", "conditions", ss.GetStatus().Conditions)
					}

					return conditionLen
				}).
					WithTimeout(time.Second*3).
					WithPolling(time.Millisecond*500).
					Should(BeTrue(), "condition should have been empty")
			}
		}

		validProvider := func(tc *testCase) {
			spc := tc.store.GetSpec()
			spc.Provider.Vault = nil
			spc.Provider.Fake = &esapi.FakeProvider{
				Data: []esapi.FakeProviderData{},
			}

			tc.assert = func() {
				Eventually(func() bool {
					ss := tc.store.Copy()
					err := k8sClient.Get(context.Background(), types.NamespacedName{
						Name:      defaultStoreName,
						Namespace: ss.GetNamespace(),
					}, ss)
					if err != nil {
						return false
					}

					if len(ss.GetStatus().Conditions) != 1 {
						return false
					}

					return ss.GetStatus().Conditions[0].Reason == esapi.ReasonStoreValid &&
						ss.GetStatus().Conditions[0].Type == esapi.SecretStoreReady &&
						ss.GetStatus().Conditions[0].Status == corev1.ConditionTrue &&
						hasEvent(tc.store.GetTypeMeta().Kind, ss.GetName(), esapi.ReasonStoreValid)
				}).
					WithTimeout(time.Second * 10).
					WithPolling(time.Second).
					Should(BeTrue())
			}

		}

		readWrite := func(tc *testCase) {
			spc := tc.store.GetSpec()
			spc.Provider.Vault = nil
			spc.Provider.Fake = &esapi.FakeProvider{
				Data: []esapi.FakeProviderData{},
			}

			tc.assert = func() {
				Eventually(func() bool {
					ss := tc.store.Copy()
					err := k8sClient.Get(context.Background(), types.NamespacedName{
						Name:      defaultStoreName,
						Namespace: ss.GetNamespace(),
					}, ss)
					if err != nil {
						return false
					}

					if ss.GetStatus().Capabilities != esapi.SecretStoreReadWrite {
						return false
					}

					return true
				}).
					WithTimeout(time.Second * 10).
					WithPolling(time.Second).
					Should(BeTrue())
			}

		}

		// an unknown store validation result should be reflected
		// in the store status condition
		validationUnknown := func(tc *testCase) {
			spc := tc.store.GetSpec()
			spc.Provider.Vault = nil
			validationResultUnknown := esapi.ValidationResultUnknown
			spc.Provider.Fake = &esapi.FakeProvider{
				Data:             []esapi.FakeProviderData{},
				ValidationResult: &validationResultUnknown,
			}

			tc.assert = func() {
				Eventually(func() bool {
					ss := tc.store.Copy()
					err := k8sClient.Get(context.Background(), types.NamespacedName{
						Name:      defaultStoreName,
						Namespace: ss.GetNamespace(),
					}, ss)
					if err != nil {
						return false
					}

					if len(ss.GetStatus().Conditions) != 1 {
						return false
					}

					return ss.GetStatus().Conditions[0].Reason == esapi.ReasonValidationUnknown &&
						ss.GetStatus().Conditions[0].Type == esapi.SecretStoreReady &&
						ss.GetStatus().Conditions[0].Status == corev1.ConditionTrue &&
						hasEvent(tc.store.GetTypeMeta().Kind, ss.GetName(), esapi.ReasonValidationUnknown)
				}).
					WithTimeout(time.Second * 5).
					WithPolling(time.Second).
					Should(BeTrue())
			}
		}

		DescribeTable("Provider Configuration", func(muts ...func(tc *testCase)) {
			for _, mut := range muts {
				mut(test)
			}
			err := k8sClient.Create(context.Background(), test.store.Copy())
			Expect(err).ToNot(HaveOccurred())
			test.assert()
		},
			// Namespaced store tests
			Entry("[namespace] invalid provider should set InvalidStore condition", invalidProvider),
			Entry("[namespace] should ignore stores with non-matching controller class", ignoreControllerClass),
			Entry("[namespace] valid provider should have status=ready", validProvider),
			Entry("[namespace] valid provider should have capabilities=ReadWrite", readWrite),
			Entry("[cluster] validation unknown status should set ValidationUnknown condition", validationUnknown),

			// Cluster store tests
			Entry("[cluster] invalid provider should set InvalidStore condition", invalidProvider, useClusterStore),
			Entry("[cluster] should ignore stores with non-matching controller class", ignoreControllerClass, useClusterStore),
			Entry("[cluster] valid provider should have status=ready", validProvider, useClusterStore),
			Entry("[cluster] valid provider should have capabilities=ReadWrite", readWrite, useClusterStore),
			Entry("[cluster] validation unknown status should set ValidationUnknown condition", validationUnknown, useClusterStore),
		)
	})

	Context("Finalizer Management", func() {
		BeforeEach(func() {
			// Setup valid provider for finalizer tests
			spc := test.store.GetSpec()
			spc.Provider.Vault = nil
			spc.Provider.Fake = &esapi.FakeProvider{
				Data: []esapi.FakeProviderData{},
			}
		})

		AfterEach(func() {
			cleanupResources(test)
		})

		DescribeTable("Finalizer Addition", func(muts ...func(tc *testCase)) {
			for _, mut := range muts {
				mut(test)
			}

			Expect(k8sClient.Create(context.Background(), test.store)).ToNot(HaveOccurred())
			Expect(k8sClient.Create(context.Background(), test.ps)).ToNot(HaveOccurred())

			Eventually(func() []string {
				return getStoreFinalizers(test.store)
			}).
				WithTimeout(time.Second * 10).
				WithPolling(time.Second).
				Should(ContainElement(secretStoreFinalizer))
		},
			Entry("[namespace] should add finalizer when PushSecret with DeletionPolicy=Delete is created", usePushSecret),
			Entry("[cluster] should add finalizer when PushSecret with DeletionPolicy=Delete is created", usePushSecret, useClusterStore),
		)

		DescribeTable("Finalizer Removal on PushSecret Deletion", func(muts ...func(tc *testCase)) {
			for _, mut := range muts {
				mut(test)
			}

			test.store.SetFinalizers([]string{secretStoreFinalizer})
			Expect(k8sClient.Create(context.Background(), test.store)).ToNot(HaveOccurred())
			Expect(k8sClient.Create(context.Background(), test.ps)).ToNot(HaveOccurred())
			Expect(k8sClient.Delete(context.Background(), test.ps)).ToNot(HaveOccurred())

			Eventually(func() []string {
				return getStoreFinalizers(test.store)
			}).
				WithTimeout(time.Second * 10).
				WithPolling(time.Second).
				ShouldNot(ContainElement(secretStoreFinalizer))
		},
			Entry("[namespace] should remove finalizer when PushSecret is deleted", usePushSecret),
			Entry("[cluster] should remove finalizer when PushSecret is deleted", usePushSecret, useClusterStore),
		)

		DescribeTable("Store Deletion Prevention", func(muts ...func(tc *testCase)) {
			for _, mut := range muts {
				mut(test)
			}

			Expect(k8sClient.Create(context.Background(), test.store)).ToNot(HaveOccurred())
			Expect(k8sClient.Create(context.Background(), test.ps)).ToNot(HaveOccurred())

			// Wait for finalizer to be added
			Eventually(func() []string {
				return getStoreFinalizers(test.store)
			}).
				WithTimeout(time.Second * 10).
				WithPolling(time.Second).
				Should(ContainElement(secretStoreFinalizer))

			Expect(k8sClient.Delete(context.Background(), test.store)).ToNot(HaveOccurred())

			Consistently(func() []string {
				return getStoreFinalizers(test.store)
			}).
				WithTimeout(time.Second * 3).
				WithPolling(time.Millisecond * 500).
				Should(ContainElement(secretStoreFinalizer))
		},
			Entry("[namespace] should prevent deletion when finalizer exists", usePushSecret),
			Entry("[cluster] should prevent deletion when finalizer exists", usePushSecret, useClusterStore),
		)

		DescribeTable("Complete Deletion Flow", func(muts ...func(tc *testCase)) {
			for _, mut := range muts {
				mut(test)
			}

			Expect(k8sClient.Create(context.Background(), test.store)).ToNot(HaveOccurred())
			Expect(k8sClient.Create(context.Background(), test.ps)).ToNot(HaveOccurred())
			Expect(k8sClient.Delete(context.Background(), test.store)).ToNot(HaveOccurred())
			Expect(k8sClient.Delete(context.Background(), test.ps)).ToNot(HaveOccurred())

			Eventually(func() bool {
				err := k8sClient.Get(context.Background(), types.NamespacedName{
					Name:      test.store.GetName(),
					Namespace: test.store.GetNamespace(),
				}, test.store)
				return apierrors.IsNotFound(err)
			}).
				WithTimeout(time.Second * 10).
				WithPolling(time.Second).
				Should(BeTrue())
		},
			Entry("[namespace] should allow deletion when both Store and PushSecret are deleted", usePushSecret),
			Entry("[cluster] should allow deletion when both Store and PushSecret are deleted", usePushSecret, useClusterStore),
		)

		DescribeTable("Multiple PushSecrets Scenario", func(muts ...func(tc *testCase)) {
			for _, mut := range muts {
				mut(test)
			}

			ps2 := test.ps.DeepCopy()
			ps2.Name = "push-secret-2"

			Expect(k8sClient.Create(context.Background(), test.store)).ToNot(HaveOccurred())
			Expect(k8sClient.Create(context.Background(), test.ps)).ToNot(HaveOccurred())
			Expect(k8sClient.Create(context.Background(), ps2)).ToNot(HaveOccurred())

			// Wait for finalizer to be added
			Eventually(func() []string {
				return getStoreFinalizers(test.store)
			}).
				WithTimeout(time.Second * 10).
				WithPolling(time.Second).
				Should(ContainElement(secretStoreFinalizer))

			Expect(k8sClient.Delete(context.Background(), test.ps)).ToNot(HaveOccurred())

			// Finalizer should remain because ps2 still exists
			Consistently(func() []string {
				return getStoreFinalizers(test.store)
			}).
				WithTimeout(time.Second * 3).
				WithPolling(time.Millisecond * 500).
				Should(ContainElement(secretStoreFinalizer))

			// Cleanup
			Expect(k8sClient.Delete(context.Background(), ps2)).ToNot(HaveOccurred())
		},
			Entry("[namespace] finalizer should remain when other PushSecrets exist", usePushSecret),
			Entry("[cluster] finalizer should remain when other PushSecrets exist", usePushSecret, useClusterStore),
		)

		DescribeTable("DeletionPolicy Change", func(muts ...func(tc *testCase)) {
			for _, mut := range muts {
				mut(test)
			}

			Expect(k8sClient.Create(context.Background(), test.store)).ToNot(HaveOccurred())
			Expect(k8sClient.Create(context.Background(), test.ps)).ToNot(HaveOccurred())

			// Wait for finalizer to be added
			Eventually(func() []string {
				return getStoreFinalizers(test.store)
			}).
				WithTimeout(time.Second * 10).
				WithPolling(time.Second).
				Should(ContainElement(secretStoreFinalizer))

			// Update PushSecret to DeletionPolicy=None
			Eventually(func() error {
				err := k8sClient.Get(context.Background(), types.NamespacedName{
					Name:      test.ps.Name,
					Namespace: test.ps.Namespace,
				}, test.ps)
				Expect(err).ToNot(HaveOccurred())
				test.ps.Spec.DeletionPolicy = esv1alpha1.PushSecretDeletionPolicyNone
				return k8sClient.Update(context.Background(), test.ps)
			}).
				WithTimeout(time.Second * 10).
				WithPolling(time.Second).
				Should(Succeed())

			Eventually(func() []string {
				return getStoreFinalizers(test.store)
			}).
				WithTimeout(time.Second * 10).
				WithPolling(time.Second).
				ShouldNot(ContainElement(secretStoreFinalizer))
		},
			Entry("[namespace] should remove finalizer when DeletionPolicy changes to None", usePushSecret),
			Entry("[cluster] should remove finalizer when DeletionPolicy changes to None", usePushSecret, useClusterStore),
		)
	})

})

func cleanupResources(test *testCase) {
	if test.ps != nil {
		err := k8sClient.Delete(context.Background(), test.ps)
		if err != nil && !apierrors.IsNotFound(err) {
			Expect(err).ToNot(HaveOccurred())
		}
	}

	err := k8sClient.Delete(context.Background(), test.store)
	if err != nil && !apierrors.IsNotFound(err) {
		Expect(err).ToNot(HaveOccurred())
	}

	Eventually(func() bool {
		err := k8sClient.Get(context.Background(), types.NamespacedName{
			Name:      test.store.GetName(),
			Namespace: test.store.GetNamespace(),
		}, test.store)
		return apierrors.IsNotFound(err)
	}).
		WithTimeout(time.Second * 10).
		WithPolling(time.Second).
		Should(BeTrue())
}

func getStoreFinalizers(store esapi.GenericStore) []string {
	err := k8sClient.Get(context.Background(), types.NamespacedName{
		Name:      store.GetName(),
		Namespace: store.GetNamespace(),
	}, store)
	if err != nil {
		return []string{}
	}
	return store.GetFinalizers()
}

func makeDefaultTestcase() *testCase {
	return &testCase{
		assert: func() {
			// this is a noop by default
		},
		store: &esapi.SecretStore{
			TypeMeta: metav1.TypeMeta{
				Kind:       esapi.SecretStoreKind,
				APIVersion: esapi.SecretStoreKindAPIVersion,
			},
			ObjectMeta: metav1.ObjectMeta{
				Name:      defaultStoreName,
				Namespace: "default",
			},
			Spec: esapi.SecretStoreSpec{
				Controller: defaultControllerClass,
				// empty provider
				// a testCase mutator must fill in the concrete provider
				Provider: &esapi.SecretStoreProvider{
					Vault: &esapi.VaultProvider{
						Version: esapi.VaultKVStoreV1,
					},
				},
			},
		},
	}
}

func useClusterStore(tc *testCase) {
	spc := tc.store.GetSpec()
	meta := tc.store.GetObjectMeta()

	tc.store = &esapi.ClusterSecretStore{
		TypeMeta: metav1.TypeMeta{
			Kind:       esapi.ClusterSecretStoreKind,
			APIVersion: esapi.ClusterSecretStoreKindAPIVersion,
		},
		ObjectMeta: metav1.ObjectMeta{
			Name: meta.Name,
		},
		Spec: *spc,
	}

	if tc.ps != nil {
		tc.ps.Spec.SecretStoreRefs[0].Kind = esapi.ClusterSecretStoreKind
	}
}

func usePushSecret(tc *testCase) {
	tc.ps = &esv1alpha1.PushSecret{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "push-secret",
			Namespace: "default",
		},
		Spec: esv1alpha1.PushSecretSpec{
			DeletionPolicy: esv1alpha1.PushSecretDeletionPolicyDelete,
			Selector: esv1alpha1.PushSecretSelector{
				Secret: &esv1alpha1.PushSecretSecret{
					Name: "foo",
				},
			},
			SecretStoreRefs: []esv1alpha1.PushSecretStoreRef{
				{
					Name: defaultStoreName,
				},
			},
		},
	}
}

func hasEvent(involvedKind, name, reason string) bool {
	el := &corev1.EventList{}
	err := k8sClient.List(context.Background(), el)
	if err != nil {
		return false
	}
	for i := range el.Items {
		ev := el.Items[i]
		if ev.InvolvedObject.Kind == involvedKind && ev.InvolvedObject.Name == name {
			if ev.Reason == reason {
				return true
			}
		}
	}
	return false
}
