package meta

import (
	"context"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/mem9-ai/drive9/pkg/metrics"
)

func TestAPIKeyResolveCacheExportsHitMissAndEntries(t *testing.T) {
	c := newAPIKeyResolveCache()
	if _, ok := c.get("metrics-miss"); ok {
		t.Fatal("unexpected cache hit")
	}
	c.fill("metrics-hit", TenantWithAPIKey{Tenant: Tenant{ID: "cache-metrics-tenant"}})
	if _, ok := c.get("metrics-hit"); !ok {
		t.Fatal("expected cache hit")
	}

	rec := httptest.NewRecorder()
	metrics.WritePrometheus(rec)
	text := rec.Body.String()
	for _, want := range []string{
		`drive9_api_key_resolve_cache_requests_total{result="miss"}`,
		`drive9_api_key_resolve_cache_requests_total{result="hit"}`,
		`drive9_api_key_resolve_cache_entries 1.000000`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("missing API key cache metric %q:\n%s", want, text)
		}
	}
}

func insertCacheTestTenant(t *testing.T, s *Store, tenantID string) {
	t.Helper()
	now := time.Now().UTC()
	if err := s.InsertTenant(context.Background(), &Tenant{
		ID:               tenantID,
		Status:           TenantActive,
		DBHost:           "127.0.0.1",
		DBPort:           4000,
		DBUser:           "root",
		DBPasswordCipher: []byte("cipher"),
		DBName:           "tenant_db",
		Provider:         "tidb_zero",
		SchemaVersion:    1,
		CreatedAt:        now,
		UpdatedAt:        now,
	}); err != nil {
		t.Fatal(err)
	}
}

func insertCacheTestKey(t *testing.T, s *Store, tenantID, keyID, hash string) {
	t.Helper()
	now := time.Now().UTC()
	if err := s.InsertAPIKey(context.Background(), &APIKey{
		ID:            keyID,
		TenantID:      tenantID,
		KeyName:       "default",
		JWTCiphertext: []byte("jwt-cipher"),
		JWTHash:       hash,
		TokenVersion:  1,
		Status:        APIKeyActive,
		IssuedAt:      now,
		CreatedAt:     now,
		UpdatedAt:     now,
	}); err != nil {
		t.Fatal(err)
	}
}

func TestResolveByAPIKeyHashCachesWithinTTL(t *testing.T) {
	s := newControlStore(t)
	insertCacheTestTenant(t, s, "cache-t1")
	insertCacheTestKey(t, s, "cache-t1", "cache-k1", "cache-hash1")

	if _, err := s.ResolveByAPIKeyHash(context.Background(), "cache-hash1"); err != nil {
		t.Fatal(err)
	}
	if _, ok := s.apiKeys.lookup("cache-hash1"); !ok {
		t.Fatal("first resolve did not populate cache")
	}
	got, err := s.ResolveByAPIKeyHash(context.Background(), "cache-hash1")
	if err != nil {
		t.Fatal(err)
	}
	if got.Tenant.ID != "cache-t1" || got.APIKey.ID != "cache-k1" {
		t.Fatalf("cached resolve = tenant %s key %s, want cache-t1/cache-k1", got.Tenant.ID, got.APIKey.ID)
	}
}

func TestResolveByAPIKeyHashDoesNotCacheNotFound(t *testing.T) {
	s := newControlStore(t)
	ctx := context.Background()

	if _, err := s.ResolveByAPIKeyHash(ctx, "cache-missing-hash"); err != ErrNotFound {
		t.Fatalf("resolve missing hash error = %v, want ErrNotFound", err)
	}
	// A newly created key must be visible immediately, even though a resolve
	// for the same hash returned ErrNotFound just before.
	insertCacheTestTenant(t, s, "cache-t2")
	insertCacheTestKey(t, s, "cache-t2", "cache-k2", "cache-missing-hash")
	got, err := s.ResolveByAPIKeyHash(ctx, "cache-missing-hash")
	if err != nil {
		t.Fatalf("resolve after insert error = %v, want nil", err)
	}
	if got.APIKey.ID != "cache-k2" {
		t.Fatalf("resolved key = %s, want cache-k2", got.APIKey.ID)
	}
}

func TestResolveByAPIKeyHashRevokeAPIKeyEvictsCache(t *testing.T) {
	s := newControlStore(t)
	ctx := context.Background()
	insertCacheTestTenant(t, s, "cache-t3")
	insertCacheTestKey(t, s, "cache-t3", "cache-k3", "cache-hash3")

	// Populate the cache; the second resolve must be served from it.
	if _, err := s.ResolveByAPIKeyHash(ctx, "cache-hash3"); err != nil {
		t.Fatal(err)
	}
	if _, err := s.ResolveByAPIKeyHash(ctx, "cache-hash3"); err != nil {
		t.Fatal(err)
	}
	if err := s.RevokeAPIKey(ctx, "cache-t3", "cache-k3"); err != nil {
		t.Fatal(err)
	}
	if _, ok := s.apiKeys.lookup("cache-hash3"); ok {
		t.Fatal("revoked API key remained cached")
	}
	revoked, err := s.ResolveByAPIKeyHash(ctx, "cache-hash3")
	if err != nil {
		t.Fatal(err)
	}
	if revoked.APIKey.Status != APIKeyRevoked {
		t.Fatalf("revoked key status = %s, want %s", revoked.APIKey.Status, APIKeyRevoked)
	}
}

func TestAPIKeyRevocationsWriteCacheCleanupOutbox(t *testing.T) {
	tests := []struct {
		name   string
		revoke func(context.Context, *Store, string, string) error
	}{
		{
			name: "single key",
			revoke: func(ctx context.Context, s *Store, tenantID, keyID string) error {
				return s.RevokeAPIKey(ctx, tenantID, keyID)
			},
		},
		{
			name: "all tenant keys",
			revoke: func(ctx context.Context, s *Store, tenantID, _ string) error {
				return s.RevokeTenantAPIKeys(ctx, tenantID)
			},
		},
		{
			name: "issuer keys",
			revoke: func(ctx context.Context, s *Store, tenantID, _ string) error {
				return s.RevokeAPIKeysByIssuer(ctx, tenantID, "slock", "subject-1", "")
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			s := newControlStore(t)
			ctx := context.Background()
			if _, err := s.DB().ExecContext(ctx, `DELETE FROM tenant_notify_outbox`); err != nil {
				t.Fatal(err)
			}
			t.Cleanup(func() {
				_, _ = s.DB().ExecContext(context.Background(), `DELETE FROM tenant_notify_outbox`)
			})
			tenantID := "cache-outbox-" + strings.ReplaceAll(tt.name, " ", "-")
			keyID := tenantID + "-key"
			insertCacheTestTenant(t, s, tenantID)
			insertCacheTestKey(t, s, tenantID, keyID, tenantID+"-hash")
			if tt.name == "issuer keys" {
				if _, err := s.DB().ExecContext(ctx, `UPDATE tenant_api_keys
					SET issued_by_provider = 'slock', issued_by_subject_key = 'subject-1'
					WHERE id = ?`, keyID); err != nil {
					t.Fatal(err)
				}
			}

			if err := tt.revoke(ctx, s, tenantID, keyID); err != nil {
				t.Fatal(err)
			}
			rows, err := s.ListTenantNotifySince(ctx, 0, 100)
			if err != nil {
				t.Fatal(err)
			}
			for _, row := range rows {
				if row.TenantID == tenantID && row.WorkMask&TenantNotifyWorkAPIKeyCacheCleanup != 0 {
					return
				}
			}
			t.Fatalf("missing durable API-key cache-cleanup outbox row for tenant %s: %+v", tenantID, rows)
		})
	}
}

func TestResolveByAPIKeyHashRevokeTenantAPIKeysEvictsCache(t *testing.T) {
	s := newControlStore(t)
	ctx := context.Background()
	insertCacheTestTenant(t, s, "cache-t4")
	insertCacheTestKey(t, s, "cache-t4", "cache-k4", "cache-hash4")

	if _, err := s.ResolveByAPIKeyHash(ctx, "cache-hash4"); err != nil {
		t.Fatal(err)
	}
	if _, err := s.ResolveByAPIKeyHash(ctx, "cache-hash4"); err != nil {
		t.Fatal(err)
	}
	if err := s.RevokeTenantAPIKeys(ctx, "cache-t4"); err != nil {
		t.Fatal(err)
	}
	if _, ok := s.apiKeys.lookup("cache-hash4"); ok {
		t.Fatal("tenant-revoked API key remained cached")
	}
	revoked, err := s.ResolveByAPIKeyHash(ctx, "cache-hash4")
	if err != nil {
		t.Fatal(err)
	}
	if revoked.APIKey.Status != APIKeyRevoked {
		t.Fatalf("tenant-revoked key status = %s, want %s", revoked.APIKey.Status, APIKeyRevoked)
	}
}

func TestInvalidateAPIKeyResolveCacheForTenantEvictsCache(t *testing.T) {
	s := newControlStore(t)
	ctx := context.Background()
	insertCacheTestTenant(t, s, "cache-t-invalidate")
	insertCacheTestKey(t, s, "cache-t-invalidate", "cache-k-invalidate", "cache-hash-invalidate")
	if _, err := s.ResolveByAPIKeyHash(ctx, "cache-hash-invalidate"); err != nil {
		t.Fatal(err)
	}

	s.InvalidateAPIKeyResolveCacheForTenant("cache-t-invalidate")
	if _, ok := s.apiKeys.lookup("cache-hash-invalidate"); ok {
		t.Fatal("tenant API key remained cached after explicit invalidation")
	}
}

func TestResolveByAPIKeyHashRevokeByIssuerEvictsCache(t *testing.T) {
	s := newControlStore(t)
	ctx := context.Background()
	insertCacheTestTenant(t, s, "cache-t5")
	insertCacheTestKey(t, s, "cache-t5", "cache-k5", "cache-hash5")
	if _, err := s.DB().ExecContext(ctx, `UPDATE tenant_api_keys
		SET issued_by_provider = 'slock', issued_by_subject_key = 'subject-1'
		WHERE id = 'cache-k5'`); err != nil {
		t.Fatal(err)
	}

	if _, err := s.ResolveByAPIKeyHash(ctx, "cache-hash5"); err != nil {
		t.Fatal(err)
	}
	if _, err := s.ResolveByAPIKeyHash(ctx, "cache-hash5"); err != nil {
		t.Fatal(err)
	}
	if err := s.RevokeAPIKeysByIssuer(ctx, "cache-t5", "slock", "subject-1", ""); err != nil {
		t.Fatal(err)
	}
	if _, ok := s.apiKeys.lookup("cache-hash5"); ok {
		t.Fatal("issuer-revoked API key remained cached")
	}
	revoked, err := s.ResolveByAPIKeyHash(ctx, "cache-hash5")
	if err != nil {
		t.Fatal(err)
	}
	if revoked.APIKey.Status != APIKeyRevoked {
		t.Fatalf("issuer-revoked key status = %s, want %s", revoked.APIKey.Status, APIKeyRevoked)
	}
}

func TestResolveByAPIKeyHashTenantStatusUpdateEvictsCache(t *testing.T) {
	s := newControlStore(t)
	ctx := context.Background()
	insertCacheTestTenant(t, s, "cache-t8")
	insertCacheTestKey(t, s, "cache-t8", "cache-k8", "cache-hash8")

	// Populate the cache; the second resolve must be served from it.
	if _, err := s.ResolveByAPIKeyHash(ctx, "cache-hash8"); err != nil {
		t.Fatal(err)
	}
	if _, err := s.ResolveByAPIKeyHash(ctx, "cache-hash8"); err != nil {
		t.Fatal(err)
	}
	if err := s.UpdateTenantStatus(ctx, "cache-t8", TenantSuspended); err != nil {
		t.Fatal(err)
	}
	if _, ok := s.apiKeys.lookup("cache-hash8"); ok {
		t.Fatal("tenant status update left stale API key resolution cached")
	}
	got, err := s.ResolveByAPIKeyHash(ctx, "cache-hash8")
	if err != nil {
		t.Fatal(err)
	}
	if got.Tenant.Status != TenantSuspended {
		t.Fatalf("tenant status after update = %s, want %s (no TTL wait)", got.Tenant.Status, TenantSuspended)
	}
}

func TestResolveByAPIKeyHashDBCredentialUpdateEvictsCache(t *testing.T) {
	s := newControlStore(t)
	ctx := context.Background()
	insertCacheTestTenant(t, s, "cache-t9")
	insertCacheTestKey(t, s, "cache-t9", "cache-k9", "cache-hash9")

	if _, err := s.ResolveByAPIKeyHash(ctx, "cache-hash9"); err != nil {
		t.Fatal(err)
	}
	if _, err := s.ResolveByAPIKeyHash(ctx, "cache-hash9"); err != nil {
		t.Fatal(err)
	}
	updated, err := s.UpdateTenantDBCredentialIf(ctx, "cache-t9", "root", "app_user", []byte("new-cipher"))
	if err != nil {
		t.Fatal(err)
	}
	if !updated {
		t.Fatal("UpdateTenantDBCredentialIf updated = false, want true")
	}
	if _, ok := s.apiKeys.lookup("cache-hash9"); ok {
		t.Fatal("credential update left stale API key resolution cached")
	}
	got, err := s.ResolveByAPIKeyHash(ctx, "cache-hash9")
	if err != nil {
		t.Fatal(err)
	}
	if got.Tenant.DBUser != "app_user" || string(got.Tenant.DBPasswordCipher) != "new-cipher" {
		t.Fatalf("credentials after update = %q/%q, want app_user/new-cipher (no TTL wait)",
			got.Tenant.DBUser, got.Tenant.DBPasswordCipher)
	}
}

func TestResolveByAPIKeyHashTTLExpiryRefetches(t *testing.T) {
	s := newControlStore(t)
	s.apiKeys.ttl = 20 * time.Millisecond
	insertCacheTestTenant(t, s, "cache-t6")
	insertCacheTestKey(t, s, "cache-t6", "cache-k6", "cache-hash6")

	if _, err := s.ResolveByAPIKeyHash(context.Background(), "cache-hash6"); err != nil {
		t.Fatal(err)
	}
	time.Sleep(40 * time.Millisecond)
	if _, ok := s.apiKeys.lookup("cache-hash6"); ok {
		t.Fatal("expired API key resolution remained cached")
	}
	if _, err := s.ResolveByAPIKeyHash(context.Background(), "cache-hash6"); err != nil {
		t.Fatal(err)
	}
}

func TestResolveByAPIKeyHashCachedEntryIsIsolatedPerCaller(t *testing.T) {
	s := newControlStore(t)
	insertCacheTestTenant(t, s, "cache-t7")
	insertCacheTestKey(t, s, "cache-t7", "cache-k7", "cache-hash7")

	first, err := s.ResolveByAPIKeyHash(context.Background(), "cache-hash7")
	if err != nil {
		t.Fatal(err)
	}
	// Populate the cache, then mutate the first returned copy in every mutable
	// field; the cached entry and later callers must stay intact.
	if _, err := s.ResolveByAPIKeyHash(context.Background(), "cache-hash7"); err != nil {
		t.Fatal(err)
	}
	first.APIKey.JWTCiphertext[0] ^= 0xff
	first.Tenant.DBPasswordCipher[0] ^= 0xff
	first.Tenant.Status = TenantSuspended

	again, err := s.ResolveByAPIKeyHash(context.Background(), "cache-hash7")
	if err != nil {
		t.Fatal(err)
	}
	if string(again.APIKey.JWTCiphertext) != "jwt-cipher" {
		t.Fatalf("cached jwt ciphertext mutated: %q", again.APIKey.JWTCiphertext)
	}
	if string(again.Tenant.DBPasswordCipher) != "cipher" {
		t.Fatalf("cached db password cipher mutated: %q", again.Tenant.DBPasswordCipher)
	}
	if again.Tenant.Status != TenantActive {
		t.Fatalf("cached tenant status mutated: %s", again.Tenant.Status)
	}
}
