package authprojection

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	iop "iop/proto/gen/iop"
)

func projectionFixture(generation uint64, now time.Time, ttl time.Duration, rawToken string) *iop.PrincipalProjection {
	projection := &iop.PrincipalProjection{
		Generation:        generation,
		IssuedAtUnixNano:  now.UnixNano(),
		ExpiresAtUnixNano: now.Add(ttl).UnixNano(),
	}
	if rawToken != "" {
		digest := sha256.Sum256([]byte(rawToken))
		projection.Tokens = []*iop.ProjectedPrincipalToken{{
			TokenDigestSha256: hex.EncodeToString(digest[:]),
			PrincipalRef:      "principal-1",
			PrincipalAlias:    "principal-one",
			TokenRef:          "token-1",
			TokenRevision:     generation,
		}}
		projection.Routes = []*iop.ProjectedPrincipalRoute{{
			RouteId: "route-1", RouteAlias: "model-1", PrincipalRef: "principal-1",
			CredentialSlotRef: "slot-1", ProfileId: "openai", UpstreamModel: "upstream-model",
			ResourceSelector: "default", RouteRevision: generation, CredentialRevision: generation,
		}}
	}
	return projection
}

func TestCacheAppliesOnlyHigherFreshGeneration(t *testing.T) {
	now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
	cache := NewCache(DefaultLimits(), func() time.Time { return now })
	if state := cache.State(); state != StateUnmanaged {
		t.Fatalf("initial state: got %s", state)
	}
	if err := cache.Apply(projectionFixture(4, now, time.Minute, "token-four")); err != nil {
		t.Fatal(err)
	}
	if generation, ok := cache.Generation(); !ok || generation != 4 {
		t.Fatalf("generation: got %d ok=%v", generation, ok)
	}
	if err := cache.Apply(projectionFixture(4, now, time.Minute, "other-token")); !errors.Is(err, ErrGenerationConflict) {
		t.Fatalf("changed equal generation: got %v", err)
	}
	if err := cache.Apply(projectionFixture(3, now, time.Minute, "other-token")); !errors.Is(err, ErrGenerationNotHigher) {
		t.Fatalf("lower generation: got %v", err)
	}
	if err := cache.Apply(projectionFixture(5, now.Add(-2*time.Minute), time.Minute, "expired")); !errors.Is(err, ErrProjectionExpired) {
		t.Fatalf("expired higher generation: got %v", err)
	}
	if err := cache.Apply(projectionFixture(5, now.Add(time.Second), time.Minute, "future")); !errors.Is(err, ErrInvalidProjection) {
		t.Fatalf("not-yet-issued higher generation: got %v", err)
	}

	digest := sha256.Sum256([]byte("token-four"))
	principal, state, ok := cache.LookupDigest(digest)
	if !ok || state != StateFresh || principal.PrincipalRef != "principal-1" || principal.TokenRef != "token-1" {
		t.Fatalf("lookup: principal=%+v state=%s ok=%v", principal, state, ok)
	}
	if generation, _ := cache.Generation(); generation != 4 {
		t.Fatalf("rejected candidates changed generation to %d", generation)
	}
}

func TestCacheRenewsOnlyIdenticalGeneration(t *testing.T) {
	start := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
	var clock atomic.Int64
	clock.Store(start.UnixNano())
	cache := NewCache(DefaultLimits(), func() time.Time { return time.Unix(0, clock.Load()) })
	if err := cache.Apply(projectionFixture(7, start, time.Minute, "renewed-token")); err != nil {
		t.Fatal(err)
	}
	original := cache.current.Load()

	if err := cache.Apply(projectionFixture(7, start, time.Minute, "renewed-token")); err != nil {
		t.Fatalf("idempotent duplicate: %v", err)
	}
	if got := cache.current.Load(); got != original {
		t.Fatal("identical duplicate replaced the installed snapshot")
	}
	if err := cache.Apply(projectionFixture(7, start.Add(-10*time.Second), 40*time.Second, "renewed-token")); err != nil {
		t.Fatalf("older freshness window: %v", err)
	}
	if got := cache.current.Load(); got != original {
		t.Fatal("older freshness window replaced the installed snapshot")
	}

	clock.Store(start.Add(10 * time.Second).UnixNano())
	if err := cache.Apply(projectionFixture(7, start.Add(10*time.Second), time.Minute, "changed-token")); !errors.Is(err, ErrGenerationConflict) {
		t.Fatalf("changed equal generation: got %v", err)
	}

	clock.Store(start.Add(20 * time.Second).UnixNano())
	if err := cache.Apply(projectionFixture(7, start.Add(20*time.Second), time.Minute, "renewed-token")); err != nil {
		t.Fatalf("renewed equal generation: %v", err)
	}
	renewed := cache.current.Load()
	if renewed == original {
		t.Fatal("later freshness window did not replace the installed snapshot")
	}
	if !renewed.issuedAt.After(original.issuedAt) || !renewed.expiresAt.After(original.expiresAt) {
		t.Fatalf("freshness window did not advance: original=%s..%s renewed=%s..%s", original.issuedAt, original.expiresAt, renewed.issuedAt, renewed.expiresAt)
	}
}

func TestCacheRecoversExpiredProjectionWithIdenticalGenerationRenewal(t *testing.T) {
	start := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
	var clock atomic.Int64
	clock.Store(start.UnixNano())
	cache := NewCache(DefaultLimits(), func() time.Time { return time.Unix(0, clock.Load()) })
	if err := cache.Apply(projectionFixture(9, start, time.Minute, "recovery-token")); err != nil {
		t.Fatal(err)
	}
	recoveredAt := start.Add(2 * time.Minute)
	clock.Store(recoveredAt.UnixNano())
	if state := cache.State(); state != StateExpired {
		t.Fatalf("state before renewal: got %s", state)
	}
	if err := cache.Apply(projectionFixture(9, recoveredAt, time.Minute, "recovery-token")); err != nil {
		t.Fatalf("same-generation expiry recovery: %v", err)
	}
	if state := cache.State(); state != StateFresh {
		t.Fatalf("state after renewal: got %s", state)
	}
}

func TestCacheExpiryKeepsManagedFailClosed(t *testing.T) {
	start := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
	var clock atomic.Int64
	clock.Store(start.UnixNano())
	cache := NewCache(DefaultLimits(), func() time.Time { return time.Unix(0, clock.Load()) })
	if err := cache.Apply(projectionFixture(1, start, time.Minute, "expiring-token")); err != nil {
		t.Fatal(err)
	}
	clock.Store(start.Add(time.Minute).UnixNano())
	if state := cache.State(); state != StateExpired {
		t.Fatalf("expired state: got %s", state)
	}
	digest := sha256.Sum256([]byte("expiring-token"))
	if _, state, ok := cache.LookupDigest(digest); ok || state != StateExpired {
		t.Fatalf("expired lookup: state=%s ok=%v", state, ok)
	}

	recoveredAt := start.Add(2 * time.Minute)
	clock.Store(recoveredAt.UnixNano())
	if err := cache.Apply(projectionFixture(2, recoveredAt, time.Minute, "replacement-token")); err != nil {
		t.Fatal(err)
	}
	if state := cache.State(); state != StateFresh {
		t.Fatalf("recovered state: got %s", state)
	}
}

func TestCacheRevocationSnapshotRemovesVerifier(t *testing.T) {
	now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
	cache := NewCache(DefaultLimits(), func() time.Time { return now })
	if err := cache.Apply(projectionFixture(1, now, time.Minute, "revoked-token")); err != nil {
		t.Fatal(err)
	}
	if err := cache.Apply(projectionFixture(2, now, time.Minute, "")); err != nil {
		t.Fatal(err)
	}
	digest := sha256.Sum256([]byte("revoked-token"))
	if _, state, ok := cache.LookupDigest(digest); ok || state != StateFresh {
		t.Fatalf("revoked lookup: state=%s ok=%v", state, ok)
	}
	if state := cache.State(); state != StateFresh {
		t.Fatalf("revocation snapshot must remain managed and fresh: %s", state)
	}
}

func TestCacheRejectsOversizedSnapshot(t *testing.T) {
	now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
	projection := projectionFixture(1, now, time.Minute, "token-one")
	second := projectionFixture(1, now, time.Minute, "token-two").GetTokens()[0]
	second.TokenRef = "token-2"
	second.PrincipalRef = "principal-2"
	projection.Tokens = append(projection.Tokens, second)

	cache := NewCache(Limits{MaxTokens: 1, MaxRoutes: 4, MaxBytes: 4096}, func() time.Time { return now })
	if err := cache.Apply(projection); !errors.Is(err, ErrProjectionTooLarge) {
		t.Fatalf("token limit: got %v", err)
	}
	byteCache := NewCache(Limits{MaxTokens: 4, MaxRoutes: 4, MaxBytes: 1}, func() time.Time { return now })
	if err := byteCache.Apply(projectionFixture(1, now, time.Minute, "token-one")); !errors.Is(err, ErrProjectionTooLarge) {
		t.Fatalf("byte limit: got %v", err)
	}
}

func TestCacheDeepCopiesAppliedProjection(t *testing.T) {
	now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
	cache := NewCache(DefaultLimits(), func() time.Time { return now })
	projection := projectionFixture(1, now, time.Minute, "immutable-token")
	if err := cache.Apply(projection); err != nil {
		t.Fatal(err)
	}
	projection.Tokens[0].PrincipalRef = "mutated"
	projection.Tokens[0].TokenDigestSha256 = strings.Repeat("0", sha256.Size*2)
	projection.Routes[0].RouteAlias = "mutated"
	projection.ExpiresAtUnixNano = now.Add(-time.Minute).UnixNano()

	digest := sha256.Sum256([]byte("immutable-token"))
	principal, state, ok := cache.LookupDigest(digest)
	if !ok || state != StateFresh || principal.PrincipalRef != "principal-1" {
		t.Fatalf("mutated input changed cache: principal=%+v state=%s ok=%v", principal, state, ok)
	}
	routes, state := cache.RoutesForPrincipal("principal-1")
	if state != StateFresh || len(routes) != 1 || routes[0].RouteAlias != "model-1" {
		t.Fatalf("mutated route changed cache: routes=%+v state=%s", routes, state)
	}
}

func TestAuthenticatedViewRetainsOneCopiedGeneration(t *testing.T) {
	now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
	cache := NewCache(DefaultLimits(), func() time.Time { return now })
	if err := cache.Apply(projectionFixture(1, now, time.Hour, "view-token")); err != nil {
		t.Fatal(err)
	}
	digest := sha256.Sum256([]byte("view-token"))
	view, ok := cache.AuthenticatedView(digest)
	if !ok || view.Generation != 1 || len(view.Routes) != 1 {
		t.Fatalf("view: %+v ok=%v", view, ok)
	}
	view.Routes[0].RouteID = "caller-mutation"
	if err := cache.Apply(projectionFixture(2, now, time.Hour, "next-token")); err != nil {
		t.Fatal(err)
	}
	if view.Generation != 1 || view.Routes[0].RouteID != "caller-mutation" {
		t.Fatalf("view changed after swap: %+v", view)
	}
	routes, _ := cache.RoutesForPrincipal("principal-1")
	if len(routes) != 1 || routes[0].RouteID != "route-1" {
		t.Fatalf("view mutation changed cache routes: %+v", routes)
	}
}

func TestCacheConcurrentLookupAndApply(t *testing.T) {
	now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
	cache := NewCache(DefaultLimits(), func() time.Time { return now })
	if err := cache.Apply(projectionFixture(0, now, time.Hour, "token-0")); err != nil {
		t.Fatal(err)
	}

	var wg sync.WaitGroup
	for reader := 0; reader < 8; reader++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for i := 0; i < 500; i++ {
				digest := sha256.Sum256([]byte("token-0"))
				_, state, _ := cache.LookupDigest(digest)
				if state == StateUnmanaged {
					t.Errorf("managed cache returned unmanaged state")
					return
				}
				_, _ = cache.RoutesForPrincipal("principal-1")
			}
		}()
	}
	for writer := 0; writer < 4; writer++ {
		writer := writer
		wg.Add(1)
		go func() {
			defer wg.Done()
			for generation := uint64(writer + 1); generation <= 200; generation += 4 {
				err := cache.Apply(projectionFixture(generation, now, time.Hour, "token-0"))
				if err != nil && !errors.Is(err, ErrGenerationNotHigher) {
					t.Errorf("apply generation %d: %v", generation, err)
					return
				}
			}
		}()
	}
	wg.Wait()
	if generation, ok := cache.Generation(); !ok || generation != 200 {
		t.Fatalf("final generation: got %d ok=%v", generation, ok)
	}
}

func TestCacheConcurrentSameGenerationRenewalAndReaders(t *testing.T) {
	start := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
	now := start.Add(5 * time.Minute)
	cache := NewCache(DefaultLimits(), func() time.Time { return now })
	if err := cache.Apply(projectionFixture(11, start, 10*time.Minute, "stable-token")); err != nil {
		t.Fatal(err)
	}

	var wg sync.WaitGroup
	for reader := 0; reader < 8; reader++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			digest := sha256.Sum256([]byte("stable-token"))
			for range 500 {
				if _, state, ok := cache.LookupDigest(digest); !ok || state != StateFresh {
					t.Errorf("renewal reader observed state=%s ok=%v", state, ok)
					return
				}
			}
		}()
	}
	for writer := 0; writer < 4; writer++ {
		writer := writer
		wg.Add(1)
		go func() {
			defer wg.Done()
			for offset := writer + 1; offset <= 100; offset += 4 {
				issuedAt := start.Add(time.Duration(offset) * time.Second)
				if err := cache.Apply(projectionFixture(11, issuedAt, 10*time.Minute, "stable-token")); err != nil {
					t.Errorf("same-generation renewal at %s: %v", issuedAt, err)
					return
				}
			}
		}()
	}
	wg.Wait()
	installed := cache.current.Load()
	wantIssuedAt := start.Add(100 * time.Second)
	if !installed.issuedAt.Equal(wantIssuedAt) {
		t.Fatalf("final renewal issued_at: got %s want %s", installed.issuedAt, wantIssuedAt)
	}
}
