/*
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 doppler

import (
	"bytes"
	"context"
	"sync"
	"sync/atomic"
	"testing"

	"github.com/google/go-cmp/cmp"
	corev1 "k8s.io/api/core/v1"

	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
	"github.com/external-secrets/external-secrets/providers/v1/doppler/client"
	"github.com/external-secrets/external-secrets/providers/v1/doppler/fake"
	"github.com/external-secrets/external-secrets/runtime/cache"
)

const testETagValue = "etag-123"

// testCacheSize is used in tests to create caches with sufficient capacity.
const testCacheSize = 100

const testAPIKeyValue = "secret-value"
const testDBPassValue = "password"

// testStore is a default store identity used in tests.
var testStore = storeIdentity{
	namespace: "test-namespace",
	name:      "test-store",
	kind:      "SecretStore",
}

func TestCacheKey(t *testing.T) {
	store := storeIdentity{namespace: "ns", name: "store", kind: "SecretStore"}

	tests := []struct {
		store      storeIdentity
		secretName string
		expected   cache.Key
	}{
		{store, "", cache.Key{Name: "store", Namespace: "ns", Kind: "SecretStore"}},
		{store, "API_KEY", cache.Key{Name: "store|API_KEY", Namespace: "ns", Kind: "SecretStore"}},
		{store, "DB_PASS", cache.Key{Name: "store|DB_PASS", Namespace: "ns", Kind: "SecretStore"}},
	}

	for _, tt := range tests {
		result := cacheKey(tt.store, tt.secretName)
		if result != tt.expected {
			t.Errorf("cacheKey(%v, %q) = %v, want %v", tt.store, tt.secretName, result, tt.expected)
		}
	}
}

func TestSecretsCacheGetSet(t *testing.T) {
	c := newSecretsCache(testCacheSize)

	entry, found := c.get(testStore, "")
	if found || entry != nil {
		t.Error("expected empty cache to return nil, false")
	}

	testEntry := &cacheEntry{
		etag:    "test-etag",
		secrets: client.Secrets{"KEY": "value"},
		body:    []byte("test body"),
	}
	c.set(testStore, "", testEntry)

	entry, found = c.get(testStore, "")
	if !found {
		t.Error("expected cache hit after set")
	}
	if entry.etag != testEntry.etag {
		t.Errorf("expected etag %q, got %q", testEntry.etag, entry.etag)
	}
	if !cmp.Equal(entry.secrets, testEntry.secrets) {
		t.Errorf("expected secrets %v, got %v", testEntry.secrets, entry.secrets)
	}

	// Different secret name should miss
	entry, found = c.get(testStore, "API_KEY")
	if found || entry != nil {
		t.Error("expected cache miss for different secret name")
	}

	// Different store should not see the entry
	otherStore := storeIdentity{namespace: "other-ns", name: "other-store", kind: "SecretStore"}
	entry, found = c.get(otherStore, "")
	if found || entry != nil {
		t.Error("expected cache miss for different store")
	}
}

func TestSecretsCacheInvalidate(t *testing.T) {
	c := newSecretsCache(testCacheSize)

	testEntry := &cacheEntry{
		etag:    "test-etag",
		secrets: client.Secrets{"KEY": "value"},
	}
	c.set(testStore, "", testEntry)
	c.set(testStore, "API_KEY", testEntry)
	c.set(testStore, "DB_PASS", testEntry)

	_, found := c.get(testStore, "")
	if !found {
		t.Error("expected cache hit before invalidate")
	}
	_, found = c.get(testStore, "API_KEY")
	if !found {
		t.Error("expected cache hit for API_KEY before invalidate")
	}

	c.invalidate(testStore)

	_, found = c.get(testStore, "")
	if found {
		t.Error("expected cache miss after invalidate")
	}
	_, found = c.get(testStore, "API_KEY")
	if found {
		t.Error("expected cache miss for API_KEY after invalidate")
	}
	_, found = c.get(testStore, "DB_PASS")
	if found {
		t.Error("expected cache miss for DB_PASS after invalidate")
	}
}

func TestSecretsCacheConcurrency(t *testing.T) {
	c := newSecretsCache(testCacheSize)
	const numGoroutines = 100
	const numIterations = 100

	var wg sync.WaitGroup
	wg.Add(numGoroutines)

	for i := range numGoroutines {
		go func(id int) {
			defer wg.Done()
			for j := range numIterations {
				entry := &cacheEntry{
					etag:    "etag",
					secrets: client.Secrets{"KEY": "value"},
				}
				c.set(testStore, "", entry)
				c.get(testStore, "")
				if j%10 == 0 {
					c.invalidate(testStore)
				}
			}
		}(i)
	}

	wg.Wait()
}

func TestGetAllSecretsUsesCache(t *testing.T) {
	etagCache = newSecretsCache(testCacheSize)

	fakeClient := &fake.DopplerClient{}

	var callCount atomic.Int32
	testSecrets := client.Secrets{"API_KEY": testAPIKeyValue, "DB_PASS": testDBPassValue}
	testETag := testETagValue

	fakeClient.WithSecretsFunc(func(request client.SecretsRequest) (*client.SecretsResponse, error) {
		count := callCount.Add(1)

		if request.ETag == "" {
			return &client.SecretsResponse{
				Modified: true,
				Secrets:  testSecrets,
				ETag:     testETag,
			}, nil
		}

		if request.ETag == testETag {
			return &client.SecretsResponse{
				Modified: false,
				Secrets:  nil,
				ETag:     testETag,
			}, nil
		}

		t.Errorf("unexpected call %d with ETag %q", count, request.ETag)
		return nil, nil
	})

	c := &Client{
		doppler:   fakeClient,
		project:   "test-project",
		config:    "test-config",
		namespace: "test-namespace",
		storeName: "test-store",
		storeKind: "SecretStore",
	}

	secrets, err := c.secrets(context.Background())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(secrets) != 2 {
		t.Errorf("expected 2 secrets, got %d", len(secrets))
	}
	if string(secrets["API_KEY"]) != testAPIKeyValue {
		t.Errorf("expected API_KEY=%s, got %s", testAPIKeyValue, secrets["API_KEY"])
	}

	secrets, err = c.secrets(context.Background())
	if err != nil {
		t.Fatalf("unexpected error on second call: %v", err)
	}
	if len(secrets) != 2 {
		t.Errorf("expected 2 secrets on second call, got %d", len(secrets))
	}

	if callCount.Load() != 2 {
		t.Errorf("expected 2 API calls, got %d", callCount.Load())
	}
}

func TestGetSecretUsesCache(t *testing.T) {
	etagCache = newSecretsCache(testCacheSize)

	fakeClient := &fake.DopplerClient{}

	var callCount atomic.Int32
	apiKeyETag := "etag-api-key"
	dbPassETag := "etag-db-pass"

	fakeClient.WithSecretFunc(func(request client.SecretRequest) (*client.SecretResponse, error) {
		callCount.Add(1)

		secretName := request.Name
		var expectedETag string
		var secretValue string

		switch secretName {
		case "API_KEY":
			expectedETag = apiKeyETag
			secretValue = testAPIKeyValue
		case "DB_PASS":
			expectedETag = dbPassETag
			secretValue = testDBPassValue
		default:
			t.Errorf("unexpected secret requested: %s", secretName)
			return nil, nil
		}

		if request.ETag == expectedETag {
			return &client.SecretResponse{Modified: false, ETag: expectedETag}, nil
		}

		return &client.SecretResponse{
			Name:     secretName,
			Value:    secretValue,
			Modified: true,
			ETag:     expectedETag,
		}, nil
	})

	c := &Client{
		doppler:   fakeClient,
		project:   "test-project",
		config:    "test-config",
		namespace: "test-namespace",
		storeName: "test-store",
		storeKind: "SecretStore",
	}

	secret, err := c.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "API_KEY"})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if string(secret) != testAPIKeyValue {
		t.Errorf("expected %s, got %s", testAPIKeyValue, secret)
	}

	secret, err = c.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "API_KEY"})
	if err != nil {
		t.Fatalf("unexpected error on second call: %v", err)
	}
	if string(secret) != testAPIKeyValue {
		t.Errorf("expected %s on second call, got %s", testAPIKeyValue, secret)
	}

	secret, err = c.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "DB_PASS"})
	if err != nil {
		t.Fatalf("unexpected error for DB_PASS: %v", err)
	}
	if string(secret) != testDBPassValue {
		t.Errorf("expected %s, got %s", testDBPassValue, secret)
	}

	secret, err = c.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "DB_PASS"})
	if err != nil {
		t.Fatalf("unexpected error on second DB_PASS call: %v", err)
	}
	if string(secret) != testDBPassValue {
		t.Errorf("expected %s on second call, got %s", testDBPassValue, secret)
	}

	if callCount.Load() != 4 {
		t.Errorf("expected 4 API calls, got %d", callCount.Load())
	}
}

func TestCacheInvalidationOnPushSecret(t *testing.T) {
	etagCache = newSecretsCache(testCacheSize)

	fakeClient := &fake.DopplerClient{}

	var secretsCallCount atomic.Int32
	testSecrets := client.Secrets{"API_KEY": "original-value"}
	updatedSecrets := client.Secrets{"API_KEY": "updated-value"}
	testETag := testETagValue
	newETag := "etag-456"

	fakeClient.WithSecretsFunc(func(request client.SecretsRequest) (*client.SecretsResponse, error) {
		count := secretsCallCount.Add(1)

		switch count {
		case 1:
			return &client.SecretsResponse{
				Modified: true,
				Secrets:  testSecrets,
				ETag:     testETag,
			}, nil
		case 2:
			if request.ETag != "" {
				t.Errorf("expected no ETag after cache invalidation, got %q", request.ETag)
			}
			return &client.SecretsResponse{
				Modified: true,
				Secrets:  updatedSecrets,
				ETag:     newETag,
			}, nil
		default:
			t.Errorf("unexpected call %d", count)
			return nil, nil
		}
	})

	fakeClient.WithUpdateValue(client.UpdateSecretsRequest{
		Secrets: client.Secrets{validRemoteKey: validSecretValue},
		Project: "test-project",
		Config:  "test-config",
	}, nil)

	c := &Client{
		doppler:   fakeClient,
		project:   "test-project",
		config:    "test-config",
		namespace: "test-namespace",
		storeName: "test-store",
		storeKind: "SecretStore",
	}

	_, err := c.secrets(context.Background())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	storeID := c.storeIdentity()
	entry, found := etagCache.get(storeID, "")
	if !found {
		t.Error("expected cache to be populated after first call")
	}
	if entry.etag != testETag {
		t.Errorf("expected ETag %q, got %q", testETag, entry.etag)
	}

	secret := &corev1.Secret{
		Data: map[string][]byte{
			validSecretName: []byte(validSecretValue),
		},
	}
	secretData := esv1alpha1.PushSecretData{
		Match: esv1alpha1.PushSecretMatch{
			SecretKey: validSecretName,
			RemoteRef: esv1alpha1.PushSecretRemoteRef{
				RemoteKey: validRemoteKey,
			},
		},
	}
	err = c.PushSecret(context.Background(), secret, secretData)
	if err != nil {
		t.Fatalf("unexpected error pushing secret: %v", err)
	}

	_, found = etagCache.get(storeID, "")
	if found {
		t.Error("expected cache to be invalidated after push")
	}

	_, err = c.secrets(context.Background())
	if err != nil {
		t.Fatalf("unexpected error after push: %v", err)
	}

	if secretsCallCount.Load() != 2 {
		t.Errorf("expected 2 secrets API calls, got %d", secretsCallCount.Load())
	}
}

func TestCacheInvalidationOnDeleteSecret(t *testing.T) {
	etagCache = newSecretsCache(testCacheSize)

	fakeClient := &fake.DopplerClient{}

	testSecrets := client.Secrets{"API_KEY": "value"}
	testETag := testETagValue

	fakeClient.WithSecretsFunc(func(_ client.SecretsRequest) (*client.SecretsResponse, error) {
		return &client.SecretsResponse{
			Modified: true,
			Secrets:  testSecrets,
			ETag:     testETag,
		}, nil
	})

	fakeClient.WithUpdateValue(client.UpdateSecretsRequest{
		ChangeRequests: []client.Change{
			{
				Name:         validRemoteKey,
				OriginalName: validRemoteKey,
				ShouldDelete: true,
			},
		},
		Project: "test-project",
		Config:  "test-config",
	}, nil)

	c := &Client{
		doppler:   fakeClient,
		project:   "test-project",
		config:    "test-config",
		namespace: "test-namespace",
		storeName: "test-store",
		storeKind: "SecretStore",
	}

	_, err := c.secrets(context.Background())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	storeID := c.storeIdentity()
	_, found := etagCache.get(storeID, "")
	if !found {
		t.Error("expected cache to be populated")
	}

	remoteRef := &esv1alpha1.PushSecretRemoteRef{RemoteKey: validRemoteKey}
	err = c.DeleteSecret(context.Background(), remoteRef)
	if err != nil {
		t.Fatalf("unexpected error deleting secret: %v", err)
	}

	_, found = etagCache.get(storeID, "")
	if found {
		t.Error("expected cache to be invalidated after delete")
	}
}

func TestCacheWithFormat(t *testing.T) {
	etagCache = newSecretsCache(testCacheSize)

	fakeClient := &fake.DopplerClient{}

	var callCount atomic.Int32
	testBody := []byte("KEY=value\nDB_PASS=password")
	testETag := "etag-format-123"

	fakeClient.WithSecretsFunc(func(request client.SecretsRequest) (*client.SecretsResponse, error) {
		count := callCount.Add(1)

		if request.ETag == "" {
			return &client.SecretsResponse{
				Modified: true,
				Body:     testBody,
				ETag:     testETag,
			}, nil
		}

		if request.ETag == testETag {
			return &client.SecretsResponse{
				Modified: false,
				Body:     nil,
				ETag:     testETag,
			}, nil
		}

		t.Errorf("unexpected call %d with ETag %q", count, request.ETag)
		return nil, nil
	})

	c := &Client{
		doppler:   fakeClient,
		project:   "test-project",
		config:    "test-config",
		format:    "env",
		namespace: "test-namespace",
		storeName: "test-store",
		storeKind: "SecretStore",
	}

	secrets, err := c.secrets(context.Background())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !bytes.Equal(secrets["DOPPLER_SECRETS_FILE"], testBody) {
		t.Errorf("expected body %q, got %q", testBody, secrets["DOPPLER_SECRETS_FILE"])
	}

	secrets, err = c.secrets(context.Background())
	if err != nil {
		t.Fatalf("unexpected error on second call: %v", err)
	}
	if !bytes.Equal(secrets["DOPPLER_SECRETS_FILE"], testBody) {
		t.Errorf("expected cached body %q, got %q", testBody, secrets["DOPPLER_SECRETS_FILE"])
	}

	if callCount.Load() != 2 {
		t.Errorf("expected 2 API calls, got %d", callCount.Load())
	}
}

func TestSecretsCacheDisabled(t *testing.T) {
	// When cache size is 0, caching should be disabled
	c := newSecretsCache(0)
	if c != nil {
		t.Error("expected nil cache when size is 0")
	}

	// Operations on nil cache should be no-ops (not panic)
	var nilCache *secretsCache
	entry, found := nilCache.get(testStore, "")
	if found || entry != nil {
		t.Error("expected nil cache get to return nil, false")
	}

	// set should be a no-op
	nilCache.set(testStore, "", &cacheEntry{etag: "test"})

	// invalidate should be a no-op
	nilCache.invalidate(testStore)
}

func TestDisabledCacheDoesNotCacheSecrets(t *testing.T) {
	// Test that when cache is disabled, secrets are fetched on every call
	etagCache = nil // Disabled cache

	fakeClient := &fake.DopplerClient{}

	var callCount atomic.Int32
	testSecrets := client.Secrets{"API_KEY": testAPIKeyValue}

	fakeClient.WithSecretsFunc(func(_ client.SecretsRequest) (*client.SecretsResponse, error) {
		callCount.Add(1)
		return &client.SecretsResponse{
			Modified: true,
			Secrets:  testSecrets,
			ETag:     "etag-123",
		}, nil
	})

	c := &Client{
		doppler:   fakeClient,
		project:   "test-project",
		config:    "test-config",
		namespace: "test-namespace",
		storeName: "test-store",
		storeKind: "SecretStore",
	}

	// First call
	_, err := c.secrets(context.Background())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	// Second call - should still fetch because cache is disabled
	_, err = c.secrets(context.Background())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	// Third call
	_, err = c.secrets(context.Background())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	// All three calls should have been made to the API
	if callCount.Load() != 3 {
		t.Errorf("expected 3 API calls with disabled cache, got %d", callCount.Load())
	}
}

func TestCacheIsolationBetweenStores(t *testing.T) {
	// Test that different stores don't share cache entries even with same project/config
	c := newSecretsCache(testCacheSize)

	storeA := storeIdentity{namespace: "ns-a", name: "store-a", kind: "SecretStore"}
	storeB := storeIdentity{namespace: "ns-b", name: "store-b", kind: "SecretStore"}

	entryA := &cacheEntry{etag: "etag-a", secrets: client.Secrets{"KEY": "value-a"}}
	entryB := &cacheEntry{etag: "etag-b", secrets: client.Secrets{"KEY": "value-b"}}

	// Both stores use same project/config but should have separate cache entries
	c.set(storeA, "", entryA)
	c.set(storeB, "", entryB)

	// Each store should get its own entry
	gotA, foundA := c.get(storeA, "")
	gotB, foundB := c.get(storeB, "")

	if !foundA {
		t.Error("expected cache hit for store A")
	}
	if !foundB {
		t.Error("expected cache hit for store B")
	}

	if gotA.etag != "etag-a" {
		t.Errorf("store A got wrong etag: %q, want %q", gotA.etag, "etag-a")
	}
	if gotB.etag != "etag-b" {
		t.Errorf("store B got wrong etag: %q, want %q", gotB.etag, "etag-b")
	}

	// Invalidating store A should not affect store B
	c.invalidate(storeA)

	_, foundA = c.get(storeA, "")
	_, foundB = c.get(storeB, "")

	if foundA {
		t.Error("expected cache miss for store A after invalidation")
	}
	if !foundB {
		t.Error("expected cache hit for store B after store A invalidation")
	}
}
