package records

import (
	"context"
	"sync"
	"testing"

	"github.com/nexuscrm/shared/pkg/models"
)

func TestRecordReadCache_HitAfterSet(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)

	record := models.SObject{"Id": "001", "Name": "Acme"}
	gen := c.beginRead("Account", "001")
	c.setIfFresh("Account", "001", record, true, gen)

	got, found, hit := c.get("Account", "001")
	if !hit {
		t.Fatal("expected cache hit")
	}
	if !found {
		t.Fatal("expected found=true")
	}
	if got["Name"] != "Acme" {
		t.Fatalf("got Name=%v; want Acme", got["Name"])
	}
}

func TestRecordReadCache_OwnerUserProofDoesNotSeedRawCache(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	SeedOwnerUserLookupProof(ctx, "usr-1")
	c := recordReadCacheFromCtx(ctx)
	if !c.hasOwnerUserProof("usr-1") {
		t.Fatal("owner user proof missing after seed")
	}
	if _, _, hit := c.get("User", "usr-1"); hit {
		t.Fatal("owner user proof must not populate generic raw read cache")
	}
}

func TestRecordReadCache_EnsureReusesExistingCache(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)
	gen := c.beginRead("User", "usr-1")
	c.setIfFresh("User", "usr-1", models.SObject{"Id": "usr-1"}, true, gen)
	ctx = EnsureRecordReadCache(ctx)
	c = recordReadCacheFromCtx(ctx)
	if _, found, hit := c.get("User", "usr-1"); !hit || !found {
		t.Fatalf("cache hit/found after EnsureRecordReadCache = %v/%v, want true/true", hit, found)
	}
}

func TestRecordReadCache_InvalidateClearsOwnerUserProof(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	SeedOwnerUserLookupProof(ctx, "usr-1")
	c := recordReadCacheFromCtx(ctx)
	c.invalidate("User", "usr-1")
	if c.hasOwnerUserProof("usr-1") {
		t.Fatal("owner user proof survived User invalidation")
	}
}

func TestRecordReadCache_MissReturnsNotHit(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)

	_, _, hit := c.get("Account", "001")
	if hit {
		t.Fatal("expected cache miss")
	}
}

func TestRecordReadCache_NotFoundCached(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)

	gen := c.beginRead("Account", "999")
	c.setIfFresh("Account", "999", nil, false, gen)

	_, found, hit := c.get("Account", "999")
	if !hit {
		t.Fatal("expected cache hit for not-found entry")
	}
	if found {
		t.Fatal("expected found=false for not-found entry")
	}
}

func TestRecordReadCache_InvalidateBypassesCache(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)

	record := models.SObject{"Id": "001", "Name": "Acme"}
	gen := c.beginRead("Account", "001")
	c.setIfFresh("Account", "001", record, true, gen)

	c.invalidate("Account", "001")

	_, _, hit := c.get("Account", "001")
	if hit {
		t.Fatal("expected cache miss after invalidation")
	}
}

func TestRecordReadCache_SeparatesLogicalAndPhysicalRows(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)
	gen := c.beginRead("Opportunity", "opp-1")
	c.setIfFreshWithMode("Opportunity", "opp-1", recordReadModeLogical, models.SObject{"Id": "opp-1", "Formula__c": "computed"}, true, gen)
	c.setIfFreshWithMode("Opportunity", "opp-1", recordReadModePhysical, models.SObject{"Id": "opp-1", "Formula__c": nil}, true, gen)

	logical, found, hit := c.getWithMode("Opportunity", "opp-1", recordReadModeLogical)
	if !hit || !found || logical["Formula__c"] != "computed" {
		t.Fatalf("logical cache result = %#v, found/hit=%v/%v", logical, found, hit)
	}
	physical, found, hit := c.getWithMode("Opportunity", "opp-1", recordReadModePhysical)
	if !hit || !found || physical["Formula__c"] != nil {
		t.Fatalf("physical cache result = %#v, found/hit=%v/%v", physical, found, hit)
	}
}

func TestRecordReadCache_InvalidateClearsLogicalAndPhysicalRows(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)
	gen := c.beginRead("Opportunity", "opp-1")
	c.setIfFreshWithMode("Opportunity", "opp-1", recordReadModeLogical, models.SObject{"Id": "opp-1"}, true, gen)
	c.setIfFreshWithMode("Opportunity", "opp-1", recordReadModePhysical, models.SObject{"Id": "opp-1"}, true, gen)

	c.invalidate("Opportunity", "opp-1")
	if _, _, hit := c.getWithMode("Opportunity", "opp-1", recordReadModeLogical); hit {
		t.Fatal("logical cache survived invalidation")
	}
	if _, _, hit := c.getWithMode("Opportunity", "opp-1", recordReadModePhysical); hit {
		t.Fatal("physical cache survived invalidation")
	}
}

func TestRecordReadCache_InvalidateRejectsStalePhysicalRead(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)
	gen := c.beginRead("Opportunity", "opp-1")
	c.invalidate("Opportunity", "opp-1")
	c.setIfFreshWithMode("Opportunity", "opp-1", recordReadModePhysical, models.SObject{"Id": "opp-1"}, true, gen)
	if _, _, hit := c.getWithMode("Opportunity", "opp-1", recordReadModePhysical); hit {
		t.Fatal("stale physical read straddling invalidation was cached")
	}
}

func TestCachedFindOneKeepsLogicalAndPhysicalRepositoryReadsSeparate(t *testing.T) {
	var calls int
	ps := &PersistenceService{repo: &stubUpsertRecordRepo{findOneFn: func(context.Context, string, string) (models.SObject, error) {
		calls++
		if calls == 1 {
			return models.SObject{"Id": "opp-1", "Formula__c": "computed"}, nil
		}
		return models.SObject{"Id": "opp-1", "Formula__c": nil}, nil
	}}}
	ctx := WithRecordReadCache(context.Background())

	logical, err := ps.cachedFindOne(ctx, "Opportunity", "opp-1")
	if err != nil || logical["Formula__c"] != "computed" {
		t.Fatalf("logical read = %#v, err=%v", logical, err)
	}
	physical, err := ps.cachedPhysicalFindOne(ctx, "Opportunity", "opp-1")
	if err != nil || physical["Formula__c"] != nil {
		t.Fatalf("physical read = %#v, err=%v", physical, err)
	}
	if calls != 2 {
		t.Fatalf("repository reads after logical and physical lookup = %d, want 2", calls)
	}
	if _, err := ps.cachedPhysicalFindOne(ctx, "Opportunity", "opp-1"); err != nil {
		t.Fatalf("cached physical read: %v", err)
	}
	if calls != 2 {
		t.Fatalf("repository reads after repeated physical lookup = %d, want 2", calls)
	}

	InvalidateRecordReadCache(ctx, "Opportunity", "opp-1")
	if _, err := ps.cachedPhysicalFindOne(ctx, "Opportunity", "opp-1"); err != nil {
		t.Fatalf("post-invalidate physical read: %v", err)
	}
	if calls != 3 {
		t.Fatalf("repository reads after invalidation = %d, want 3", calls)
	}
}

func TestRecordReadCache_StaleReadDroppedAcrossInvalidate(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)

	// A read begins, an invalidate lands mid-flight, then the stale read tries
	// to set. The set must be dropped because its generation is behind.
	gen := c.beginRead("Account", "001")
	c.invalidate("Account", "001")
	c.setIfFresh("Account", "001", models.SObject{"Id": "001", "Name": "Stale"}, true, gen)

	_, _, hit := c.get("Account", "001")
	if hit {
		t.Fatal("expected stale read straddling invalidate to be dropped")
	}
}

func TestRecordReadCache_RepopulatesAfterInvalidate(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)

	gen := c.beginRead("Account", "001")
	c.setIfFresh("Account", "001", models.SObject{"Id": "001", "Name": "Acme"}, true, gen)
	c.invalidate("Account", "001")

	// A fresh read entirely after the invalidate must re-populate the cache —
	// the previous sticky-dirty design permanently blacklisted the key, forcing
	// every later parent re-read to fall through to the wide view.
	gen2 := c.beginRead("Account", "001")
	c.setIfFresh("Account", "001", models.SObject{"Id": "001", "Name": "Acme2"}, true, gen2)

	got, found, hit := c.get("Account", "001")
	if !hit || !found {
		t.Fatal("expected cache to re-populate after a post-invalidate read")
	}
	if got["Name"] != "Acme2" {
		t.Fatalf("got Name=%v; want Acme2", got["Name"])
	}
}

func TestRecordReadCache_CaseInsensitiveTable(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)

	record := models.SObject{"Id": "001", "Name": "Acme"}
	gen := c.beginRead("Account", "001")
	c.setIfFresh("Account", "001", record, true, gen)

	got, found, hit := c.get("account", "001")
	if !hit || !found {
		t.Fatal("expected case-insensitive table match")
	}
	if got["Name"] != "Acme" {
		t.Fatalf("got Name=%v; want Acme", got["Name"])
	}
}

func TestRecordReadCache_DefensiveCopy(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)

	record := models.SObject{"Id": "001", "Name": "Acme"}
	gen := c.beginRead("Account", "001")
	c.setIfFresh("Account", "001", record, true, gen)

	got, _, _ := c.get("Account", "001")
	got["Name"] = "Mutated"

	got2, _, _ := c.get("Account", "001")
	if got2["Name"] != "Acme" {
		t.Fatal("cache entry was mutated by caller")
	}
}

func TestRecordReadCache_NilContextPassthrough(t *testing.T) {
	c := recordReadCacheFromCtx(context.Background())
	if c != nil {
		t.Fatal("expected nil cache from plain context")
	}
}

func TestRecordReadCache_ConcurrentAccess(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)

	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			gen := c.beginRead("User", "u1")
			c.setIfFresh("User", "u1", models.SObject{"Id": "u1"}, true, gen)
			c.get("User", "u1")
			c.invalidate("User", "u1")
		}()
	}
	wg.Wait()
}

func TestRecordReadCache_EmptyIDNoop(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)

	gen := c.beginRead("Account", "")
	c.setIfFresh("Account", "", models.SObject{"Id": ""}, true, gen)
	_, _, hit := c.get("Account", "")
	if hit {
		t.Fatal("expected no-op for empty ID")
	}
}

func TestRecordReadCache_InvalidateAfterBulkInsert(t *testing.T) {
	ctx := WithRecordReadCache(context.Background())
	c := recordReadCacheFromCtx(ctx)

	// Simulate a not-found result being cached (e.g., from a lookup validation
	// that ran before the bulk insert).
	gen := c.beginRead("Account", "bulk-001")
	c.setIfFresh("Account", "bulk-001", nil, false, gen)

	_, found, hit := c.get("Account", "bulk-001")
	if !hit {
		t.Fatal("expected cache hit for not-found entry before bulk insert")
	}
	if found {
		t.Fatal("expected found=false before bulk insert")
	}

	// Simulate the bulk insert path calling InvalidateRecordReadCache.
	InvalidateRecordReadCache(ctx, "Account", "bulk-001")

	// After invalidation, the cache must NOT serve the stale not-found entry.
	// Any subsequent read for this key bypasses the cache (returns miss),
	// forcing the caller to go to the DB and see the newly inserted row.
	_, _, hit = c.get("Account", "bulk-001")
	if hit {
		t.Fatal("expected cache miss after invalidation — stale not-found must not be served")
	}

	// A stale read that began before the invalidate still cannot re-populate
	// the cache for this key.
	c.setIfFresh("Account", "bulk-001", models.SObject{"Id": "bulk-001"}, true, gen)
	_, _, hit = c.get("Account", "bulk-001")
	if hit {
		t.Fatal("expected cache miss after stale set on an invalidated generation")
	}

	// A fresh read after invalidation can repopulate; otherwise the request would
	// permanently fall back to wide view reads after the first child write.
	freshGen := c.beginRead("Account", "bulk-001")
	c.setIfFresh("Account", "bulk-001", models.SObject{"Id": "bulk-001"}, true, freshGen)
	_, found, hit = c.get("Account", "bulk-001")
	if !hit || !found {
		t.Fatal("expected fresh post-invalidate read to repopulate the cache")
	}
}
