package credentialstore

import (
	"context"
	"errors"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"google.golang.org/protobuf/proto"
)

func TestProjectionGenerationCommitsWithMutation(t *testing.T) {
	ctx := context.Background()
	registry := newFakeKeyRegistry()
	registry.RegisterKey("k1", 1)
	store, err := Open(ctx, filepath.Join(t.TempDir(), "projection-generation.db"), WithEnvelopeKeyRegistry(registry))
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()

	assertGeneration := func(want uint64) {
		t.Helper()
		got, err := store.ProjectionGeneration(ctx)
		if err != nil {
			t.Fatal(err)
		}
		if got != want {
			t.Fatalf("generation: got %d want %d", got, want)
		}
	}
	assertGeneration(0)

	issued, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "projection-principal"})
	if err != nil {
		t.Fatal(err)
	}
	assertGeneration(1)
	if _, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "projection-principal"}); !errors.Is(err, ErrPrincipalAlreadyExists) {
		t.Fatalf("duplicate create: got %v", err)
	}
	assertGeneration(1)

	slot, err := store.CreateSlot(ctx, CreateSlotInput{
		PrincipalID: issued.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer,
		Alias: "primary", Envelope: SecretEnvelope{
			Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1,
			Nonce: []byte("123456789012"), Ciphertext: []byte("opaque-ciphertext"),
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	assertGeneration(2)

	route, err := store.CreateRoute(ctx, CreateRouteInput{
		PrincipalID: issued.Principal.ID, SlotID: slot.ID, Alias: "model-primary",
		ProfileID: "openai", UpstreamModel: "upstream-model",
	})
	if err != nil {
		t.Fatal(err)
	}
	assertGeneration(3)

	if _, err := store.DisableRoute(ctx, issued.Principal.ID, route.ID, route.Revision); err != nil {
		t.Fatal(err)
	}
	assertGeneration(4)
	currentSlot, err := store.GetSlot(ctx, issued.Principal.ID, slot.ID)
	if err != nil {
		t.Fatal(err)
	}
	rotatedSlot, err := store.RotateSlotSecret(ctx, RotateSlotSecretInput{
		PrincipalID: issued.Principal.ID, SlotID: slot.ID, CurrentRevision: currentSlot.Revision,
		Envelope: SecretEnvelope{
			Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1,
			Nonce: []byte("abcdefghijkl"), Ciphertext: []byte("rotated-opaque-ciphertext"),
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	assertGeneration(5)
	if _, err := store.DisableSlot(ctx, issued.Principal.ID, slot.ID, rotatedSlot.Revision); err != nil {
		t.Fatal(err)
	}
	assertGeneration(6)
	if _, err := store.DisableToken(ctx, issued.Principal.ID, issued.Token.TokenRef, issued.Token.Revision); err != nil {
		t.Fatal(err)
	}
	assertGeneration(7)
}

func TestProjectionGenerationPersistsAcrossReopen(t *testing.T) {
	ctx := context.Background()
	dbPath := filepath.Join(t.TempDir(), "projection-reopen.db")
	store, err := Open(ctx, dbPath)
	if err != nil {
		t.Fatal(err)
	}
	issued, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "persistent-projection"})
	if err != nil {
		t.Fatal(err)
	}
	want, err := store.ProjectionGeneration(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if err := store.Close(); err != nil {
		t.Fatal(err)
	}

	reopened, err := Open(ctx, dbPath)
	if err != nil {
		t.Fatal(err)
	}
	defer reopened.Close()
	got, err := reopened.ProjectionGeneration(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if got != want {
		t.Fatalf("persisted generation: got %d want %d", got, want)
	}
	if _, err := reopened.RevokeToken(ctx, issued.Principal.ID, issued.Token.TokenRef, issued.Token.Revision); err != nil {
		t.Fatal(err)
	}
	after, err := reopened.ProjectionGeneration(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if after != want+1 {
		t.Fatalf("generation after reopen mutation: got %d want %d", after, want+1)
	}
}

func TestProjectionSnapshotOmitsInactiveRecords(t *testing.T) {
	ctx := context.Background()
	store, issued, slot, _ := setupRouteTestFixture(t)
	route, err := store.CreateRoute(ctx, CreateRouteInput{
		PrincipalID: issued.Principal.ID,
		SlotID:      slot.ID, Alias: "projected-route", ProfileID: "openai", UpstreamModel: "upstream-model",
	})
	if err != nil {
		t.Fatal(err)
	}
	clock := func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }

	snapshot, err := store.BuildPrincipalProjection(ctx, ProjectionBuildOptions{TTL: time.Minute, Clock: clock})
	if err != nil {
		t.Fatal(err)
	}
	if len(snapshot.GetTokens()) != 1 || len(snapshot.GetRoutes()) != 1 {
		t.Fatalf("active snapshot: tokens=%d routes=%d", len(snapshot.GetTokens()), len(snapshot.GetRoutes()))
	}
	if snapshot.GetTokens()[0].GetTokenDigestSha256() != issued.Token.Digest {
		t.Fatal("snapshot did not contain the active token digest")
	}
	projectedRoute := snapshot.GetRoutes()[0]
	if projectedRoute.GetCredentialSlotRef() != slot.ID || projectedRoute.GetRouteId() != route.ID {
		t.Fatalf("route projection mismatch: %+v", projectedRoute)
	}
	encoded, err := proto.Marshal(snapshot)
	if err != nil {
		t.Fatal(err)
	}
	if strings.Contains(string(encoded), "cipher1") || strings.Contains(string(encoded), "AES-256-GCM") || strings.Contains(string(encoded), "k1") {
		t.Fatal("projection contains credential envelope material")
	}

	if _, err := store.DisableToken(ctx, issued.Principal.ID, issued.Token.TokenRef, issued.Token.Revision); err != nil {
		t.Fatal(err)
	}
	currentSlot, err := store.GetSlot(ctx, issued.Principal.ID, slot.ID)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := store.DisableSlot(ctx, issued.Principal.ID, slot.ID, currentSlot.Revision); err != nil {
		t.Fatal(err)
	}
	inactiveSnapshot, err := store.BuildPrincipalProjection(ctx, ProjectionBuildOptions{TTL: time.Minute, Clock: clock})
	if err != nil {
		t.Fatal(err)
	}
	if len(inactiveSnapshot.GetTokens()) != 0 || len(inactiveSnapshot.GetRoutes()) != 0 {
		t.Fatalf("inactive rows leaked: tokens=%d routes=%d", len(inactiveSnapshot.GetTokens()), len(inactiveSnapshot.GetRoutes()))
	}
	if inactiveSnapshot.GetGeneration() <= snapshot.GetGeneration() {
		t.Fatalf("generation did not advance: before=%d after=%d", snapshot.GetGeneration(), inactiveSnapshot.GetGeneration())
	}
}

func TestProjectionSnapshotEnforcesBounds(t *testing.T) {
	ctx := context.Background()
	store := newTestStore(t)
	if _, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "bounded-a"}); err != nil {
		t.Fatal(err)
	}
	if _, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "bounded-b"}); err != nil {
		t.Fatal(err)
	}
	_, err := store.BuildPrincipalProjection(ctx, ProjectionBuildOptions{
		TTL:    time.Minute,
		Limits: ProjectionLimits{MaxTokens: 1, MaxRoutes: 1, MaxBytes: 4096},
	})
	if !errors.Is(err, ErrProjectionTooLarge) {
		t.Fatalf("token bound: got %v", err)
	}

	_, err = store.BuildPrincipalProjection(ctx, ProjectionBuildOptions{
		TTL:    time.Minute,
		Limits: ProjectionLimits{MaxTokens: 2, MaxRoutes: 1, MaxBytes: 1},
	})
	if !errors.Is(err, ErrProjectionTooLarge) {
		t.Fatalf("byte bound: got %v", err)
	}
}
