package records

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"log"
	"regexp"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/nexuscrm/kernel/pkg/apierr"
	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/metadata"
	"github.com/nexuscrm/permissions"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/contract"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/nexuscrm/shared/pkg/queryspec"
)

// fanoutQueryStub captures every QueryWithSpec call so tests can assert on
// per-object query shape (filter, ScanAll, select fields) and inject canned
// results or errors keyed by object API name.
type fanoutQueryStub struct {
	ports.QueryService

	results map[string][]models.SObject
	errs    map[string]error
	hooks   map[string]func(context.Context) error

	requireScopedRead            map[string]bool
	forbidScopedRead             map[string]bool
	requireScopedReadEnforcesFLS map[string]bool

	calls      atomic.Int64
	specs      []queryspec.QuerySpec
	contexts   []context.Context
	capturesMu sync.Mutex
}

func (s *fanoutQueryStub) QueryWithSpec(ctx context.Context, spec queryspec.QuerySpec, _ *models.UserSession) (*models.QueryResult, error) {
	s.calls.Add(1)
	s.capturesMu.Lock()
	s.specs = append(s.specs, spec)
	s.contexts = append(s.contexts, ctx)
	s.capturesMu.Unlock()

	if hook := s.hooks[spec.ObjectAPIName]; hook != nil {
		if err := hook(ctx); err != nil {
			return nil, err
		}
	}
	if err, ok := s.errs[spec.ObjectAPIName]; ok {
		return nil, err
	}
	if s.requireScopedRead[spec.ObjectAPIName] && !constants.IsScopedInternalAccess(ctx, spec.ObjectAPIName, constants.PermRead) {
		return nil, apierr.Forbidden("scoped read required for this test object")
	}
	if s.requireScopedReadEnforcesFLS[spec.ObjectAPIName] && !constants.ScopedInternalAccessEnforcesFLS(ctx, spec.ObjectAPIName, constants.PermRead) {
		return nil, apierr.Forbidden("FLS-preserving scoped read required for this test object")
	}
	if s.forbidScopedRead[spec.ObjectAPIName] && constants.IsScopedInternalAccess(ctx, spec.ObjectAPIName, constants.PermRead) {
		return nil, apierr.Forbidden("scope=mine must not use scoped internal read")
	}
	rows := s.results[spec.ObjectAPIName]
	return &models.QueryResult{TotalSize: len(rows), Records: rows, Done: true}, nil
}

// fanoutRepoStub stubs only the recycle-bin tombstone reads of
// ports.RecordRepository; the embedded interface traps any other call with
// a nil panic, the loud signal that production reached for an unmodeled API.
type fanoutRepoStub struct {
	ports.RecordRepository

	byUser      map[string][]models.SObject
	all         []models.SObject
	byUserPages map[string][][]models.SObject
	allPages    [][]models.SObject
	errByUser   error
	errAll      error

	lastDeleterID     string // deleter stable id passed to the page query
	byUserPageIndexes map[string]int
	allPageIndex      int
	pageCalls         []fanoutRepoPageCall
}

type fanoutRepoPageCall struct {
	scope            RecycleBinScope
	username         string
	afterDeletedDate string
	afterID          string
	limit            int
}

func (r *fanoutRepoStub) FindAllRecycleBinItems(_ context.Context) ([]models.SObject, error) {
	if r.errAll != nil {
		return nil, r.errAll
	}
	return r.all, nil
}

func (r *fanoutRepoStub) FindRecycleBinItemsByUserPage(_ context.Context, deleterID string, afterDeletedDate string, afterID string, limit int) ([]models.SObject, error) {
	r.lastDeleterID = deleterID
	// scope=mine keys on the stable DeletedById only; byUserPages is keyed by id.
	key := deleterID
	r.pageCalls = append(r.pageCalls, fanoutRepoPageCall{
		scope:            RecycleBinScopeMine,
		username:         key,
		afterDeletedDate: afterDeletedDate,
		afterID:          afterID,
		limit:            limit,
	})
	if r.errByUser != nil {
		return nil, r.errByUser
	}
	if len(r.byUserPages) == 0 {
		return r.byUser[deleterID], nil
	}
	if r.byUserPageIndexes == nil {
		r.byUserPageIndexes = map[string]int{}
	}
	idx := r.byUserPageIndexes[key]
	r.byUserPageIndexes[key] = idx + 1
	pages := r.byUserPages[key]
	if idx >= len(pages) {
		return nil, nil
	}
	return pages[idx], nil
}

func (r *fanoutRepoStub) FindAllRecycleBinItemsPage(_ context.Context, afterDeletedDate string, afterID string, limit int) ([]models.SObject, error) {
	r.pageCalls = append(r.pageCalls, fanoutRepoPageCall{
		scope:            RecycleBinScopeAll,
		afterDeletedDate: afterDeletedDate,
		afterID:          afterID,
		limit:            limit,
	})
	if r.errAll != nil {
		return nil, r.errAll
	}
	if len(r.allPages) == 0 {
		return r.all, nil
	}
	idx := r.allPageIndex
	r.allPageIndex++
	if idx >= len(r.allPages) {
		return nil, nil
	}
	return r.allPages[idx], nil
}

// --- helpers ---

func makeFanoutFixture(t *testing.T, stub *fanoutQueryStub) *PersistenceService {
	t.Helper()
	return &PersistenceService{querySvc: stub}
}

func makeFanoutMetadataService(t *testing.T, schemas ...*models.ObjectMetadata) *metadata.MetadataService {
	t.Helper()
	return metadata.NewMetadataService(&fanoutMetadataRepo{schemas: schemas}, nil, nil)
}

type fanoutMetadataRepo struct {
	ports.MetadataRepository
	schemas       []*models.ObjectMetadata
	recordTypes   []*models.RecordType
	relationships []*models.Relationship
}

func (r *fanoutMetadataRepo) GetAllSchemas(_ context.Context) ([]*models.ObjectMetadata, error) {
	return r.schemas, nil
}

func (r *fanoutMetadataRepo) GetAllRelationships(_ context.Context) ([]*models.Relationship, error) {
	return r.relationships, nil
}

func (r *fanoutMetadataRepo) GetAllFlowDefinitions(_ context.Context) ([]*models.FlowDefinition, error) {
	return nil, nil
}

func (r *fanoutMetadataRepo) GetAllValidationRules(_ context.Context) ([]*models.ValidationRule, error) {
	return nil, nil
}

func (r *fanoutMetadataRepo) GetSharingRules(_ context.Context, _ string) ([]*models.SharingRule, error) {
	return nil, nil
}

func (r *fanoutMetadataRepo) GetRecordTypes(_ context.Context, objectAPIName string) ([]*models.RecordType, error) {
	var out []*models.RecordType
	for _, recordType := range r.recordTypes {
		if recordType != nil && strings.EqualFold(recordType.ObjectAPIName, objectAPIName) {
			out = append(out, recordType)
		}
	}
	return out, nil
}

func (r *fanoutMetadataRepo) GetSchemaByAPIName(_ context.Context, apiName string) (*models.ObjectMetadata, error) {
	for _, schema := range r.schemas {
		if schema != nil && strings.EqualFold(schema.APIName, apiName) {
			return schema, nil
		}
	}
	return nil, nil
}

func fanoutObjectMetadata(apiName string) *models.ObjectMetadata {
	return &models.ObjectMetadata{
		APIName:   apiName,
		TableType: constants.TableTypeStandardObject,
		Fields: []models.FieldMetadata{
			{APIName: constants.FieldID, IsSystem: true},
			{APIName: constants.FieldName, IsNameField: true},
			{APIName: constants.FieldIsDeleted, IsSystem: true},
			{APIName: constants.FieldCreatedDate, IsSystem: true},
			{APIName: constants.FieldLastModifiedDate, IsSystem: true},
		},
	}
}

type fanoutPermRepo struct {
	ports.PermissionRepository
	systemPerms map[string]bool
	objectPerms map[string]*models.ObjectPermissions
	fieldPerms  map[string]*models.FieldPermissions
}

func (r *fanoutPermRepo) GetAllRoles(_ context.Context) ([]*models.UserRole, error) {
	return nil, nil
}

func (r *fanoutPermRepo) GetEffectiveSystemPermissions(_ context.Context, _, _ string) (map[string]bool, error) {
	out := map[string]bool{}
	for k, v := range r.systemPerms {
		out[k] = v
	}
	return out, nil
}

func (r *fanoutPermRepo) LoadEffectiveObjectPermission(_ context.Context, _ *models.UserSession, objectAPIName string) (*models.ObjectPermissions, error) {
	return r.objectPerms[strings.ToLower(objectAPIName)], nil
}

func (r *fanoutPermRepo) LoadEffectiveFieldPermission(_ context.Context, _ *models.UserSession, objectAPIName, fieldAPIName string) (*models.FieldPermissions, error) {
	return r.fieldPerms[strings.ToLower(objectAPIName)+"."+strings.ToLower(fieldAPIName)], nil
}

func newFanoutPermissionService(repo *fanoutPermRepo, meta *metadata.MetadataService) *permissions.PermissionService {
	return permissions.NewPermissionService(repo, meta, nil)
}

func tombstoneRow(recordID, objectAPIName, recordName, deletedBy, deletedDate string) models.SObject {
	return models.SObject{
		constants.FieldSysRecycleBin_ID: "rb-" + recordID,
		constants.FieldRecordID:         recordID,
		constants.FieldObjectAPIName:    objectAPIName,
		constants.FieldRecordName:       recordName,
		constants.FieldDeletedBy:        deletedBy,
		constants.FieldDeletedDate:      deletedDate,
	}
}

func liveDeletedRow(id, name, createdDate, lastModifiedDate string) models.SObject {
	return models.SObject{
		constants.FieldID:               id,
		constants.FieldName:             name,
		constants.FieldIsDeleted:        true,
		constants.FieldCreatedDate:      createdDate,
		constants.FieldLastModifiedDate: lastModifiedDate,
	}
}

func tombstone(recordID, objectAPIName, recordName, deletedBy, deletedDate string) recycleBinTombstone {
	return recycleBinTombstone{
		recordID:      recordID,
		objectAPIName: objectAPIName,
		recordName:    recordName,
		deletedBy:     deletedBy,
		deletedDate:   deletedDate,
	}
}

func testRecordID(prefix string, ordinal int) string {
	return fmt.Sprintf("%s-%03d", prefix, ordinal)
}

func testDeletedDate(ordinal int) string {
	seconds := 3599 - ordinal
	if seconds < 0 {
		seconds = 0
	}
	return fmt.Sprintf("2026-05-03T10:%02d:%02dZ", seconds/60, seconds%60)
}

func tombstonePage(objectAPIName, prefix, deletedBy string, start, count int) []models.SObject {
	rows := make([]models.SObject, 0, count)
	for i := 0; i < count; i++ {
		ordinal := start + i
		id := testRecordID(prefix, ordinal)
		rows = append(rows, tombstoneRow(id, objectAPIName, "snapshot "+id, deletedBy, testDeletedDate(ordinal)))
	}
	return rows
}

func appendLiveRows(rows []models.SObject, ids ...string) []models.SObject {
	for _, id := range ids {
		rows = append(rows, liveDeletedRow(id, "live "+id, "2026-04-01T00:00:00Z", "2026-05-10T12:00:00Z"))
	}
	return rows
}

func singleObjectGroup(apiName, nameField string, toms ...recycleBinTombstone) recycleBinObjectGroup {
	g := recycleBinObjectGroup{
		apiName:           apiName,
		nameField:         nameField,
		nameFieldReadable: true,
		tombstones:        map[string]recycleBinTombstone{},
	}
	for _, t := range toms {
		g.recordIDs = append(g.recordIDs, t.recordID)
		g.tombstones[t.recordID] = t
	}
	return g
}

func TestIsRecycleBinManagedObject_UsesObjectLifecycleMetadata(t *testing.T) {
	cases := []struct {
		name   string
		schema *models.ObjectMetadata
		want   bool
	}{
		{name: "nil schema", schema: nil, want: false},
		{name: "standard object", schema: &models.ObjectMetadata{APIName: "Account", TableType: constants.TableTypeStandardObject}, want: true},
		{name: "custom object", schema: &models.ObjectMetadata{APIName: "Issue__c", TableType: constants.TableTypeCustomObject}, want: true},
		{name: "built-in non-prefixed soft delete object", schema: &models.ObjectMetadata{APIName: "Dashboard", TableType: constants.TableType("system")}, want: true},
		{name: "blank table type compatibility", schema: &models.ObjectMetadata{APIName: "LegacyObject"}, want: true},
		{name: "hard delete data object", schema: &models.ObjectMetadata{APIName: "ArchivedThing", TableType: constants.TableTypeStandardObject, DeleteMode: "hard"}, want: false},
		{name: "system metadata", schema: &models.ObjectMetadata{APIName: "_System_Object", TableType: constants.TableTypeSystemMetadata}, want: false},
		{name: "system data", schema: &models.ObjectMetadata{APIName: "_System_Comment", TableType: constants.TableTypeSystemData}, want: false},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := isRecycleBinManagedObject(tc.schema); got != tc.want {
				t.Fatalf("isRecycleBinManagedObject(%+v) = %v, want %v", tc.schema, got, tc.want)
			}
		})
	}
}

func TestPlanRecycleBinGroups_SkipsObjectsNotManagedByRecycleBin(t *testing.T) {
	account := fanoutObjectMetadata("Account")
	dashboard := fanoutObjectMetadata("Dashboard")
	dashboard.TableType = constants.TableType("system")
	systemObject := fanoutObjectMetadata("_System_Object")
	systemObject.TableType = constants.TableTypeSystemMetadata

	ps := &PersistenceService{
		metadata: makeFanoutMetadataService(t, account, dashboard, systemObject),
	}
	tombstones := []recycleBinTombstone{
		{recordID: "a1", objectAPIName: "Account", recordName: "Account A"},
		{recordID: "d1", objectAPIName: "Dashboard", recordName: "Executive Dashboard"},
		{recordID: "s1", objectAPIName: "_System_Object", recordName: "System Object"},
	}

	groups, err := ps.planRecycleBinGroups(context.Background(), &models.UserSession{ID: "u1"}, RecycleBinScopeAll, tombstones)
	if err != nil {
		t.Fatalf("planRecycleBinGroups: %v", err)
	}
	if len(groups) != 2 {
		t.Fatalf("groups = %+v, want Account and Dashboard groups", groups)
	}
	if groups[0].apiName != "Account" || groups[1].apiName != "Dashboard" {
		t.Fatalf("groups = %+v, want Account then Dashboard", groups)
	}
}

// --- scope validation + nil-input gates ---

func TestGetRecycleBinItems_NilUserRejected(t *testing.T) {
	ps := makeFanoutFixture(t, &fanoutQueryStub{})
	_, err := ps.GetRecycleBinItems(context.Background(), nil, RecycleBinScopeMine)
	mustAPIErrStatus(t, err, 400)
}

func TestGetRecycleBinItems_InvalidScopeRejected(t *testing.T) {
	ps := makeFanoutFixture(t, &fanoutQueryStub{})
	_, err := ps.GetRecycleBinItems(context.Background(), &models.UserSession{ID: "u1", Name: "Alice"}, RecycleBinScope("everyone"))
	mustAPIErrStatus(t, err, 400)
}

func TestGetRecycleBinItems_NoQuerySvcRejected(t *testing.T) {
	// SetQueryService never called -> querySvc nil -> 500
	ps := &PersistenceService{}
	_, err := ps.GetRecycleBinItems(context.Background(), &models.UserSession{ID: "u1", Name: "Alice"}, RecycleBinScopeMine)
	mustAPIErrStatus(t, err, 500)
}

// --- scope=all permission gate ---

func TestGetRecycleBinItems_ScopeAll_NonAdminWithoutPermServiceForbidden(t *testing.T) {
	ps := makeFanoutFixture(t, &fanoutQueryStub{})
	_, err := ps.GetRecycleBinItems(context.Background(), &models.UserSession{ID: "u1", Name: "Alice"}, RecycleBinScopeAll)
	mustAPIErrStatus(t, err, 403)
}

func TestGetRecycleBinItems_ScopeAll_ViewAllDataUsesScopedReadForObjectWithoutRead(t *testing.T) {
	const objectAPIName = "IndexedObject"
	user := &models.UserSession{ID: "u1", ProfileID: "p1", Name: "Alice"}
	meta := makeFanoutMetadataService(t, fanoutObjectMetadata(objectAPIName))
	permRepo := &fanoutPermRepo{
		systemPerms: map[string]bool{string(constants.PermViewAllData): true},
		objectPerms: map[string]*models.ObjectPermissions{
			strings.ToLower(objectAPIName): {SobjectType: objectAPIName, PermissionsRead: false},
		},
	}
	permSvc := newFanoutPermissionService(permRepo, meta)
	if permSvc.CheckObjectPermissionWithUser(context.Background(), objectAPIName, constants.PermRead, user) {
		t.Fatal("test setup invalid: user unexpectedly has object Read")
	}

	repo := &fanoutRepoStub{
		allPages: [][]models.SObject{{
			tombstoneRow("i1", objectAPIName, "snapshot i1", "Bob", "2026-05-03T10:00:00Z"),
		}},
	}
	stub := &fanoutQueryStub{
		requireScopedRead:            map[string]bool{objectAPIName: true},
		requireScopedReadEnforcesFLS: map[string]bool{objectAPIName: true},
		results: map[string][]models.SObject{
			objectAPIName: {liveDeletedRow("i1", "live i1", "2026-04-01T00:00:00Z", "2026-05-10T12:00:00Z")},
		},
	}
	ps := &PersistenceService{
		repo:        repo,
		metadata:    meta,
		permissions: permSvc,
		querySvc:    stub,
	}

	result, err := ps.GetRecycleBinItems(context.Background(), user, RecycleBinScopeAll)
	if err != nil {
		t.Fatalf("GetRecycleBinItems: %v", err)
	}
	if len(result.FailedObjects) != 0 {
		t.Fatalf("FailedObjects = %+v, want none when scoped read is applied", result.FailedObjects)
	}
	if len(result.Items) != 1 || result.Items[0].ID != "i1" {
		t.Fatalf("scope=all item missing for ViewAllData-only caller; got %+v", result.Items)
	}
	ctx := lastQueryContext(t, stub)
	if !constants.IsScopedInternalAccess(ctx, objectAPIName, constants.PermRead) {
		t.Fatal("QueryWithSpec did not receive scoped internal read context")
	}
	if !constants.ScopedInternalAccessEnforcesFLS(ctx, objectAPIName, constants.PermRead) {
		t.Fatal("scope=all scoped read must preserve FLS")
	}
	if constants.ScopedInternalAccessBypassesFLS(ctx, objectAPIName, constants.PermRead) {
		t.Fatal("scope=all scoped read must not use the legacy FLS bypass")
	}
}

func TestGetRecycleBinItems_InvalidScopeReturnsBeforeRepoWiringCheck(t *testing.T) {
	// repo is nil here; an invalid scope must still surface as 400, not the
	// 500 wiring error, because request validation precedes the repo check.
	ps := makeFanoutFixture(t, &fanoutQueryStub{})
	_, err := ps.GetRecycleBinItems(context.Background(), &models.UserSession{ID: "u1", Name: "Alice"}, RecycleBinScope("nope"))
	mustAPIErrStatus(t, err, 400)
}

// --- phase 1: delete-event authority (paged tombstone load) ---

func TestLoadDeleteEventAuthorityPage_ScopeMine_MatchesDeletedById(t *testing.T) {
	repo := &fanoutRepoStub{
		byUserPages: map[string][][]models.SObject{
			"u1": {{
				tombstoneRow("a1", "Account", "Acme", "Alice", "2026-05-03T10:00:00Z"),
				tombstoneRow("o1", "Opportunity", "Big Deal", "Alice", "2026-05-02T10:00:00Z"),
			}},
		},
	}
	ps := &PersistenceService{repo: repo}

	toms, _, rawCount, exhausted, err := ps.loadDeleteEventAuthorityPage(context.Background(), &models.UserSession{ID: "u1", Name: "Alice"}, RecycleBinScopeMine, recycleBinPageCursor{}, recycleBinAggregateLimit)
	if err != nil {
		t.Fatalf("loadDeleteEventAuthorityPage: %v", err)
	}
	if repo.lastDeleterID != "u1" {
		t.Errorf("FindRecycleBinItemsByUserPage called with deleterID %q, want stable id %q", repo.lastDeleterID, "u1")
	}
	if rawCount != 2 || !exhausted {
		t.Errorf("rawCount/exhausted = %d/%v, want 2/true", rawCount, exhausted)
	}
	if len(toms) != 2 {
		t.Fatalf("tombstones = %d, want 2", len(toms))
	}
	if toms[0].deletedBy != "Alice" || toms[0].recordID != "a1" || toms[0].objectAPIName != "Account" {
		t.Errorf("first tombstone = %+v, unexpected fields", toms[0])
	}
}

func TestLoadDeleteEventAuthorityPage_ScopeMine_NoIdentityReturnsNothing(t *testing.T) {
	repo := &fanoutRepoStub{
		byUserPages: map[string][][]models.SObject{"": {{tombstoneRow("a1", "Account", "Acme", "", "2026-05-03T10:00:00Z")}}},
	}
	ps := &PersistenceService{repo: repo}

	// A caller with neither a stable id nor a display Name has no key to match.
	toms, _, rawCount, exhausted, err := ps.loadDeleteEventAuthorityPage(context.Background(), &models.UserSession{ID: "  ", Name: "   "}, RecycleBinScopeMine, recycleBinPageCursor{}, recycleBinAggregateLimit)
	if err != nil {
		t.Fatalf("loadDeleteEventAuthorityPage: %v", err)
	}
	if len(toms) != 0 || rawCount != 0 || !exhausted {
		t.Fatalf("identity-less caller must match no tombstones; got len=%d raw=%d exhausted=%v", len(toms), rawCount, exhausted)
	}
	if repo.lastDeleterID != "" {
		t.Errorf("repo should not be queried for an identity-less caller; called with id=%q", repo.lastDeleterID)
	}
}

func TestLoadDeleteEventAuthorityPage_ScopeMine_KeysOnStableIdNotName(t *testing.T) {
	// Two users share the display Name "Bob" but have distinct stable ids.
	// Each must see only their own tombstones — the collision the Name key had
	// (pingcap-inc/NexusCRM#1750).
	repo := &fanoutRepoStub{
		byUserPages: map[string][][]models.SObject{
			"u1": {{tombstoneRow("a1", "Account", "U1 Acct", "Bob", "2026-05-03T10:00:00Z")}},
			"u2": {{tombstoneRow("a2", "Account", "U2 Acct", "Bob", "2026-05-03T10:00:00Z")}},
		},
	}
	ps := &PersistenceService{repo: repo}

	toms, _, _, _, err := ps.loadDeleteEventAuthorityPage(context.Background(), &models.UserSession{ID: "u1", Name: "Bob"}, RecycleBinScopeMine, recycleBinPageCursor{}, recycleBinAggregateLimit)
	if err != nil {
		t.Fatalf("loadDeleteEventAuthorityPage: %v", err)
	}
	if repo.lastDeleterID != "u1" {
		t.Errorf("repo queried with deleterID %q, want stable id %q", repo.lastDeleterID, "u1")
	}
	if len(toms) != 1 || toms[0].recordID != "a1" {
		t.Fatalf("u1 must see only their own tombstone, not the same-named u2's; got %+v", toms)
	}
}

func TestLoadDeleteEventAuthorityPage_ScopeMine_RenamedUserStillMatchesById(t *testing.T) {
	// The caller's display Name no longer matches the delete-time snapshot, but
	// their stable id does — they keep seeing their own deletions after a rename.
	repo := &fanoutRepoStub{
		byUserPages: map[string][][]models.SObject{
			"u1": {{tombstoneRow("a1", "Account", "Acme", "Old Name", "2026-05-03T10:00:00Z")}},
		},
	}
	ps := &PersistenceService{repo: repo}

	toms, _, _, _, err := ps.loadDeleteEventAuthorityPage(context.Background(), &models.UserSession{ID: "u1", Name: "New Name"}, RecycleBinScopeMine, recycleBinPageCursor{}, recycleBinAggregateLimit)
	if err != nil {
		t.Fatalf("loadDeleteEventAuthorityPage: %v", err)
	}
	if len(toms) != 1 || toms[0].recordID != "a1" {
		t.Fatalf("renamed user must still match their own tombstone by id; got %+v", toms)
	}
}

func TestLoadDeleteEventAuthorityPage_ScopeAll_LoadsEveryTombstone(t *testing.T) {
	repo := &fanoutRepoStub{
		allPages: [][]models.SObject{{
			tombstoneRow("a1", "Account", "Acme", "Alice", "2026-05-03T10:00:00Z"),
			tombstoneRow("o1", "Opportunity", "Big Deal", "Bob", "2026-05-02T10:00:00Z"),
		}},
	}
	ps := &PersistenceService{repo: repo}

	toms, _, rawCount, exhausted, err := ps.loadDeleteEventAuthorityPage(context.Background(), &models.UserSession{ID: "u1", Name: "Alice"}, RecycleBinScopeAll, recycleBinPageCursor{}, recycleBinAggregateLimit)
	if err != nil {
		t.Fatalf("loadDeleteEventAuthorityPage: %v", err)
	}
	if len(toms) != 2 || rawCount != 2 || !exhausted {
		t.Fatalf("scope=all tombstones/raw/exhausted = %d/%d/%v, want 2/2/true", len(toms), rawCount, exhausted)
	}
}

func TestLoadDeleteEventAuthorityPage_SkipsRowsMissingKeys(t *testing.T) {
	repo := &fanoutRepoStub{
		allPages: [][]models.SObject{{
			tombstoneRow("", "Account", "Acme", "Alice", "2026-05-03T10:00:00Z"),    // no recordID
			tombstoneRow("o1", "", "Big Deal", "Bob", "2026-05-02T10:00:00Z"),       // no object
			tombstoneRow("c1", "Contact", "Carol", "Carol", "2026-05-01T10:00:00Z"), // ok
		}},
	}
	ps := &PersistenceService{repo: repo}

	toms, _, rawCount, _, err := ps.loadDeleteEventAuthorityPage(context.Background(), &models.UserSession{ID: "u1", Name: "Alice"}, RecycleBinScopeAll, recycleBinPageCursor{}, recycleBinAggregateLimit)
	if err != nil {
		t.Fatalf("loadDeleteEventAuthorityPage: %v", err)
	}
	if rawCount != 3 {
		t.Fatalf("raw rows must still count for cursor advancement; rawCount=%d, want 3", rawCount)
	}
	if len(toms) != 1 || toms[0].recordID != "c1" {
		t.Fatalf("only the well-formed tombstone should survive; got %+v", toms)
	}
}

func TestLoadDeleteEventAuthorityPage_RepoErrorWrappedAsInternal(t *testing.T) {
	repo := &fanoutRepoStub{errAll: errors.New("pq: connection reset")}
	ps := &PersistenceService{repo: repo}

	_, _, _, _, err := ps.loadDeleteEventAuthorityPage(context.Background(), &models.UserSession{ID: "u1", Name: "Alice"}, RecycleBinScopeAll, recycleBinPageCursor{}, recycleBinAggregateLimit)
	mustAPIErrStatus(t, err, 500)
}

func TestGetRecycleBinItems_PagesUntilPostVisibilityLimit(t *testing.T) {
	const objectAPIName = "IndexedObject"
	page1 := tombstonePage(objectAPIName, "hidden", "Alice", 0, recycleBinAggregateLimit)
	page2 := tombstonePage(objectAPIName, "visible", "Alice", recycleBinAggregateLimit, 5)

	visible := []models.SObject{}
	for i := 0; i < 5; i++ {
		visible = appendLiveRows(visible, testRecordID("visible", recycleBinAggregateLimit+i))
	}
	repo := &fanoutRepoStub{
		byUserPages: map[string][][]models.SObject{"u1": {page1, page2}},
	}
	stub := &fanoutQueryStub{results: map[string][]models.SObject{objectAPIName: visible}}
	ps := &PersistenceService{
		repo:     repo,
		metadata: makeFanoutMetadataService(t, fanoutObjectMetadata(objectAPIName)),
		querySvc: stub,
	}

	result, err := ps.GetRecycleBinItems(context.Background(), &models.UserSession{ID: "u1", Name: "Alice"}, RecycleBinScopeMine)
	if err != nil {
		t.Fatalf("GetRecycleBinItems: %v", err)
	}
	if len(result.FailedObjects) != 0 {
		t.Fatalf("FailedObjects = %+v, want none", result.FailedObjects)
	}
	if len(result.Items) != 5 {
		t.Fatalf("items = %d, want the 5 older visible rows behind the first invisible page", len(result.Items))
	}
	if len(repo.pageCalls) < 2 {
		t.Fatalf("repo page calls = %d, want multiple pages", len(repo.pageCalls))
	}
	for _, item := range result.Items {
		if !strings.HasPrefix(item.ID, "visible-") {
			t.Fatalf("returned invisible/pre-cap row %+v; want only older visible rows", item)
		}
	}
}

func TestGetRecycleBinItems_TruncatesVisibleResultsAfterPostVisibilityLimit(t *testing.T) {
	const objectAPIName = "IndexedObject"
	page1 := tombstonePage(objectAPIName, "r", "Alice", 0, recycleBinAggregateLimit)
	page2 := tombstonePage(objectAPIName, "r", "Alice", recycleBinAggregateLimit, 100)

	visible := []models.SObject{}
	for i := 0; i < 150; i++ {
		visible = appendLiveRows(visible, testRecordID("r", i))
	}
	for i := recycleBinAggregateLimit; i < recycleBinAggregateLimit+100; i++ {
		visible = appendLiveRows(visible, testRecordID("r", i))
	}
	repo := &fanoutRepoStub{
		byUserPages: map[string][][]models.SObject{"u1": {page1, page2}},
	}
	stub := &fanoutQueryStub{results: map[string][]models.SObject{objectAPIName: visible}}
	ps := &PersistenceService{
		repo:     repo,
		metadata: makeFanoutMetadataService(t, fanoutObjectMetadata(objectAPIName)),
		querySvc: stub,
	}

	result, err := ps.GetRecycleBinItems(context.Background(), &models.UserSession{ID: "u1", Name: "Alice"}, RecycleBinScopeMine)
	if err != nil {
		t.Fatalf("GetRecycleBinItems: %v", err)
	}
	if len(result.Items) != recycleBinAggregateLimit {
		t.Fatalf("items = %d, want post-visibility cap %d", len(result.Items), recycleBinAggregateLimit)
	}
	if len(repo.pageCalls) != 2 {
		t.Fatalf("repo page calls = %d, want 2 so older visible rows can fill the post-visibility cap", len(repo.pageCalls))
	}
	for i := 1; i < len(result.Items); i++ {
		if result.Items[i-1].DeletedDate < result.Items[i].DeletedDate {
			t.Fatalf("items not sorted DeletedDate DESC at %d: %s before %s", i, result.Items[i-1].DeletedDate, result.Items[i].DeletedDate)
		}
	}
	if result.Items[0].ID != testRecordID("r", 0) {
		t.Fatalf("first item = %q, want newest visible r-000", result.Items[0].ID)
	}
	if result.Items[len(result.Items)-1].ID != testRecordID("r", 249) {
		t.Fatalf("last item = %q, want post-visibility truncation at r-249", result.Items[len(result.Items)-1].ID)
	}
}

func TestGetRecycleBinItems_LogsCandidateScanTruncation(t *testing.T) {
	const objectAPIName = "IndexedObject"
	pages := make([][]models.SObject, 0, recycleBinCandidateScanLimit/recycleBinCandidatePageSize)
	for start := 0; start < recycleBinCandidateScanLimit; start += recycleBinCandidatePageSize {
		pages = append(pages, tombstonePage(objectAPIName, "hidden", "Alice", start, recycleBinCandidatePageSize))
	}
	repo := &fanoutRepoStub{
		byUserPages: map[string][][]models.SObject{"u1": pages},
	}
	stub := &fanoutQueryStub{results: map[string][]models.SObject{objectAPIName: nil}}
	ps := &PersistenceService{
		repo:     repo,
		metadata: makeFanoutMetadataService(t, fanoutObjectMetadata(objectAPIName)),
		querySvc: stub,
	}

	var buf bytes.Buffer
	oldWriter := log.Writer()
	oldFlags := log.Flags()
	log.SetOutput(&buf)
	log.SetFlags(0)
	t.Cleanup(func() {
		log.SetOutput(oldWriter)
		log.SetFlags(oldFlags)
	})

	result, err := ps.GetRecycleBinItems(context.Background(), &models.UserSession{ID: "u1", Name: "Alice"}, RecycleBinScopeMine)
	if err != nil {
		t.Fatalf("GetRecycleBinItems: %v", err)
	}
	if len(result.Items) != 0 {
		t.Fatalf("items = %d, want none when every scanned candidate is invisible", len(result.Items))
	}
	if len(repo.pageCalls) != recycleBinCandidateScanLimit/recycleBinCandidatePageSize {
		t.Fatalf("repo page calls = %d, want scan limit pages", len(repo.pageCalls))
	}
	if got := buf.String(); !strings.Contains(got, "recycle-bin candidate scan truncated") ||
		!strings.Contains(got, "scanned=2000") ||
		!strings.Contains(got, "visible=0") {
		t.Fatalf("truncation log missing details; got %q", got)
	}
}

// --- phase 2: per-object visibility query construction ---

func TestQueryVisibleRecycleBinRows_BuildsInPredicateAndScanAll(t *testing.T) {
	stub := &fanoutQueryStub{}
	ps := makeFanoutFixture(t, stub)
	group := singleObjectGroup("Account", "Name",
		tombstone("a1", "Account", "Acme", "Alice", "2026-05-03T10:00:00Z"),
		tombstone("a2", "Account", "Globex", "Alice", "2026-05-02T10:00:00Z"),
	)

	_, _ = ps.queryVisibleRecycleBinRows(context.Background(), &models.UserSession{ID: "u1", Name: "Alice"}, RecycleBinScopeMine, group)

	spec := lastSpec(t, stub)
	if spec.ObjectAPIName != "Account" {
		t.Errorf("ObjectAPIName = %q, want Account", spec.ObjectAPIName)
	}
	if !spec.ScanAll {
		t.Errorf("ScanAll must be true (queryAll semantics for IsDeleted rows)")
	}

	isDeleted := findCriterion(t, spec, constants.FieldIsDeleted)
	if isDeleted.Op != "=" || isDeleted.Val != true {
		t.Errorf("IsDeleted criterion = %+v, want = true", isDeleted)
	}
	idIn := findCriterion(t, spec, constants.FieldID)
	if strings.ToUpper(idIn.Op) != "IN" {
		t.Errorf("Id criterion Op = %q, want IN", idIn.Op)
	}
	vals, ok := idIn.Val.([]interface{})
	if !ok {
		t.Fatalf("Id IN value must be []interface{} (repo type-asserts that exact shape); got %T", idIn.Val)
	}
	if len(vals) != 2 {
		t.Fatalf("Id IN list = %v, want the 2 tombstoned ids", vals)
	}
}

func TestQueryVisibleRecycleBinRows_SelectsRecordFactsAndNameField(t *testing.T) {
	stub := &fanoutQueryStub{}
	ps := makeFanoutFixture(t, stub)
	group := singleObjectGroup("Account", "Name", tombstone("a1", "Account", "Acme", "Alice", "2026-05-03T10:00:00Z"))

	_, _ = ps.queryVisibleRecycleBinRows(context.Background(), &models.UserSession{ID: "u1"}, RecycleBinScopeMine, group)

	spec := lastSpec(t, stub)
	for _, want := range []string{constants.FieldID, constants.FieldIsDeleted, constants.FieldCreatedDate, constants.FieldLastModifiedDate, "Name"} {
		if !containsField(spec.SelectFields, want) {
			t.Errorf("SelectFields missing %q; got %v", want, spec.SelectFields)
		}
	}
	// LastModifiedById / relationship traversal is no longer needed: DeletedBy
	// comes from the tombstone, not the live row.
	if containsField(spec.SelectFields, constants.FieldLastModifiedByID) {
		t.Errorf("SelectFields should not request LastModifiedById; DeletedBy is tombstone-sourced now")
	}
}

func TestQueryVisibleRecycleBinRows_EmptyGroupSkipsQuery(t *testing.T) {
	stub := &fanoutQueryStub{}
	ps := makeFanoutFixture(t, stub)
	group := recycleBinObjectGroup{apiName: "Account", nameField: "Name", tombstones: map[string]recycleBinTombstone{}}

	rows, err := ps.queryVisibleRecycleBinRows(context.Background(), &models.UserSession{ID: "u1"}, RecycleBinScopeMine, group)
	if err != nil || rows != nil {
		t.Fatalf("empty group should return (nil, nil); got rows=%v err=%v", rows, err)
	}
	if stub.calls.Load() != 0 {
		t.Errorf("empty group must not issue a query; calls=%d", stub.calls.Load())
	}
}

// --- phase 2: projection merges tombstone authority with live record facts ---

func TestQueryVisibleRecycleBinRows_MergesTombstoneAndLiveFacts(t *testing.T) {
	stub := &fanoutQueryStub{
		results: map[string][]models.SObject{
			"Account": {liveDeletedRow("a1", "Acme Renamed", "2026-04-01T00:00:00Z", "2026-05-10T12:00:00Z")},
		},
	}
	ps := makeFanoutFixture(t, stub)
	group := singleObjectGroup("Account", "Name",
		tombstone("a1", "Account", "Acme (snapshot)", "Alice", "2026-05-03T10:00:00Z"),
	)

	rows, err := ps.queryVisibleRecycleBinRows(context.Background(), &models.UserSession{ID: "u1"}, RecycleBinScopeMine, group)
	if err != nil {
		t.Fatalf("queryVisibleRecycleBinRows: %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("rows = %d, want 1", len(rows))
	}
	got := rows[0]
	// Delete-event authority from the tombstone.
	if got.DeletedBy != "Alice" {
		t.Errorf("DeletedBy = %q, want tombstone deleter %q", got.DeletedBy, "Alice")
	}
	if got.DeletedDate != "2026-05-03T10:00:00Z" {
		t.Errorf("DeletedDate = %q, want tombstone delete time", got.DeletedDate)
	}
	// Record facts from the live (FLS-gated) read.
	if got.RecordName != "Acme Renamed" {
		t.Errorf("RecordName = %q, want live name (FLS-gated), not tombstone snapshot", got.RecordName)
	}
	if got.CreatedDate != "2026-04-01T00:00:00Z" {
		t.Errorf("CreatedDate = %q, want live record CreatedDate", got.CreatedDate)
	}
	if got.LastModifiedDate != "2026-05-10T12:00:00Z" {
		t.Errorf("LastModifiedDate = %q, want live record LastModifiedDate", got.LastModifiedDate)
	}
	if got.ID != "a1" || got.RecordID != "a1" {
		t.Errorf("ID/RecordID = %q/%q, want a1", got.ID, got.RecordID)
	}
}

func TestQueryVisibleRecycleBinRows_FLSHiddenNameIsNotLeakedFromTombstone(t *testing.T) {
	// The object HAS a name field but the FLS-gated live read omitted it
	// (field-level security hid it). The tombstone's delete-time snapshot
	// MUST NOT be used as a fallback — that would leak a value the caller
	// is not permitted to read (#1738 Codex round 3). Name stays blank.
	stub := &fanoutQueryStub{
		results: map[string][]models.SObject{
			"Account": {{
				constants.FieldID:               "a1",
				constants.FieldIsDeleted:        true,
				constants.FieldCreatedDate:      "2026-04-01T00:00:00Z",
				constants.FieldLastModifiedDate: "2026-05-10T12:00:00Z",
			}},
		},
	}
	ps := makeFanoutFixture(t, stub)
	group := singleObjectGroup("Account", "Name", tombstone("a1", "Account", "Acme (snapshot)", "Alice", "2026-05-03T10:00:00Z"))

	rows, _ := ps.queryVisibleRecycleBinRows(context.Background(), &models.UserSession{ID: "u1"}, RecycleBinScopeMine, group)
	if len(rows) != 1 {
		t.Fatalf("rows = %d, want 1", len(rows))
	}
	if rows[0].RecordName != "" {
		t.Fatalf("FLS-hidden name leaked from tombstone; RecordName = %q, want blank", rows[0].RecordName)
	}
}

func TestQueryVisibleRecycleBinRows_ScopeAll_DoesNotLeakFLSHiddenName(t *testing.T) {
	stub := &fanoutQueryStub{
		results: map[string][]models.SObject{
			"IndexedObject": {liveDeletedRow("i1", "live name should not leak", "2026-04-01T00:00:00Z", "2026-05-10T12:00:00Z")},
		},
	}
	ps := makeFanoutFixture(t, stub)
	group := singleObjectGroup("IndexedObject", constants.FieldName, tombstone("i1", "IndexedObject", "snapshot should not leak", "Alice", "2026-05-03T10:00:00Z"))
	group.nameFieldReadable = false

	rows, err := ps.queryVisibleRecycleBinRows(context.Background(), &models.UserSession{ID: "u1"}, RecycleBinScopeAll, group)
	if err != nil {
		t.Fatalf("queryVisibleRecycleBinRows: %v", err)
	}
	spec := lastSpec(t, stub)
	if containsField(spec.SelectFields, constants.FieldName) {
		t.Fatalf("SelectFields leaked hidden name field under scoped read: %v", spec.SelectFields)
	}
	if len(rows) != 1 {
		t.Fatalf("rows = %d, want 1", len(rows))
	}
	if rows[0].RecordName != "" {
		t.Fatalf("FLS-hidden name leaked under scoped read; RecordName = %q, want blank", rows[0].RecordName)
	}
	ctx := lastQueryContext(t, stub)
	if !constants.IsScopedInternalAccess(ctx, "IndexedObject", constants.PermRead) {
		t.Fatal("scope=all query did not use scoped internal read")
	}
	if !constants.ScopedInternalAccessEnforcesFLS(ctx, "IndexedObject", constants.PermRead) {
		t.Fatal("scope=all query did not use FLS-preserving scoped read")
	}
	if constants.ScopedInternalAccessBypassesFLS(ctx, "IndexedObject", constants.PermRead) {
		t.Fatal("scope=all query must not bypass FLS")
	}
}

func TestQueryVisibleRecycleBinRows_ScopeMine_UsesPlainContext(t *testing.T) {
	stub := &fanoutQueryStub{
		forbidScopedRead: map[string]bool{"IndexedObject": true},
		results: map[string][]models.SObject{
			"IndexedObject": {liveDeletedRow("i1", "live i1", "2026-04-01T00:00:00Z", "2026-05-10T12:00:00Z")},
		},
	}
	ps := makeFanoutFixture(t, stub)
	group := singleObjectGroup("IndexedObject", constants.FieldName, tombstone("i1", "IndexedObject", "snapshot i1", "Alice", "2026-05-03T10:00:00Z"))

	rows, err := ps.queryVisibleRecycleBinRows(context.Background(), &models.UserSession{ID: "u1"}, RecycleBinScopeMine, group)
	if err != nil {
		t.Fatalf("queryVisibleRecycleBinRows: %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("rows = %d, want 1", len(rows))
	}
	ctx := lastQueryContext(t, stub)
	if constants.IsScopedInternalAccess(ctx, "IndexedObject", constants.PermRead) {
		t.Fatal("scope=mine must not use scoped internal read")
	}
	if constants.ScopedInternalAccessEnforcesFLS(ctx, "IndexedObject", constants.PermRead) ||
		constants.ScopedInternalAccessBypassesFLS(ctx, "IndexedObject", constants.PermRead) {
		t.Fatal("scope=mine must not carry scoped FLS policy")
	}
}

func TestQueryVisibleRecycleBinRows_NoNameFieldUsesTombstoneSnapshot(t *testing.T) {
	// The object has NO declared name field (nameField == ""), so the live
	// read could never return one and there is no FLS to honor. The
	// tombstone snapshot is the only available label.
	stub := &fanoutQueryStub{
		results: map[string][]models.SObject{
			"Junction__x": {{
				constants.FieldID:               "j1",
				constants.FieldIsDeleted:        true,
				constants.FieldCreatedDate:      "2026-04-01T00:00:00Z",
				constants.FieldLastModifiedDate: "2026-05-10T12:00:00Z",
			}},
		},
	}
	ps := makeFanoutFixture(t, stub)
	group := singleObjectGroup("Junction__x", "", tombstone("j1", "Junction__x", "Link 42", "Alice", "2026-05-03T10:00:00Z"))

	rows, _ := ps.queryVisibleRecycleBinRows(context.Background(), &models.UserSession{ID: "u1"}, RecycleBinScopeMine, group)
	if len(rows) != 1 || rows[0].RecordName != "Link 42" {
		t.Fatalf("expected tombstone snapshot for no-name-field object; got %+v", rows)
	}
}

func TestQueryVisibleRecycleBinRows_RLSDropsRowsNotReturnedByQuery(t *testing.T) {
	// The tombstone names two records but the visibility query (RLS) only
	// returns one; the dropped record must not appear.
	stub := &fanoutQueryStub{
		results: map[string][]models.SObject{
			"Account": {liveDeletedRow("a1", "Acme", "2026-04-01T00:00:00Z", "2026-05-10T12:00:00Z")},
		},
	}
	ps := makeFanoutFixture(t, stub)
	group := singleObjectGroup("Account", "Name",
		tombstone("a1", "Account", "Acme", "Alice", "2026-05-03T10:00:00Z"),
		tombstone("a2", "Account", "Hidden", "Alice", "2026-05-02T10:00:00Z"),
	)

	rows, _ := ps.queryVisibleRecycleBinRows(context.Background(), &models.UserSession{ID: "u1"}, RecycleBinScopeMine, group)
	if len(rows) != 1 || rows[0].ID != "a1" {
		t.Fatalf("RLS-dropped record leaked into result; got %+v", rows)
	}
}

func TestQueryVisibleRecycleBinRows_SkipsVisibleRowWithoutTombstone(t *testing.T) {
	// A returned row whose id is not in the tombstone map has no
	// delete-event authority and must be skipped.
	stub := &fanoutQueryStub{
		results: map[string][]models.SObject{
			"Account": {liveDeletedRow("rogue", "Rogue", "2026-04-01T00:00:00Z", "2026-05-10T12:00:00Z")},
		},
	}
	ps := makeFanoutFixture(t, stub)
	group := singleObjectGroup("Account", "Name", tombstone("a1", "Account", "Acme", "Alice", "2026-05-03T10:00:00Z"))

	rows, _ := ps.queryVisibleRecycleBinRows(context.Background(), &models.UserSession{ID: "u1"}, RecycleBinScopeMine, group)
	if len(rows) != 0 {
		t.Fatalf("row without a tombstone must be skipped; got %+v", rows)
	}
}

// --- fan-out aggregation + partial failure ---

func TestFanOutVisibility_AggregatesAcrossObjects(t *testing.T) {
	stub := &fanoutQueryStub{
		results: map[string][]models.SObject{
			"Account":     {liveDeletedRow("a1", "Acme", "2026-04-01T00:00:00Z", "2026-05-03T10:00:00Z")},
			"Opportunity": {liveDeletedRow("o1", "Deal", "2026-04-01T00:00:00Z", "2026-05-02T10:00:00Z")},
		},
	}
	ps := makeFanoutFixture(t, stub)
	groups := []recycleBinObjectGroup{
		singleObjectGroup("Account", "Name", tombstone("a1", "Account", "Acme", "Alice", "2026-05-03T10:00:00Z")),
		singleObjectGroup("Opportunity", "Name", tombstone("o1", "Opportunity", "Deal", "Alice", "2026-05-02T10:00:00Z")),
	}

	items, failures, err := ps.fanOutRecycleBinVisibility(context.Background(), &models.UserSession{ID: "u1"}, RecycleBinScopeMine, groups)
	if err != nil {
		t.Fatalf("fanOutRecycleBinVisibility: %v", err)
	}
	if len(failures) != 0 {
		t.Errorf("failures = %d, want 0", len(failures))
	}
	if len(items) != 2 {
		t.Errorf("items = %d, want 2 (one per object)", len(items))
	}
}

func TestFanOutVisibility_PerObjectFailureCapturedWithoutAbortingOthers(t *testing.T) {
	stub := &fanoutQueryStub{
		results: map[string][]models.SObject{
			"Account": {liveDeletedRow("a1", "Acme", "2026-04-01T00:00:00Z", "2026-05-03T10:00:00Z")},
		},
		errs: map[string]error{
			"Opportunity": apierr.Forbidden("no FLS on Opportunity for this user"),
		},
	}
	ps := makeFanoutFixture(t, stub)
	groups := []recycleBinObjectGroup{
		singleObjectGroup("Account", "Name", tombstone("a1", "Account", "Acme", "Alice", "2026-05-03T10:00:00Z")),
		singleObjectGroup("Opportunity", "Name", tombstone("o1", "Opportunity", "Deal", "Alice", "2026-05-02T10:00:00Z")),
	}

	items, failures, err := ps.fanOutRecycleBinVisibility(context.Background(), &models.UserSession{ID: "u1"}, RecycleBinScopeMine, groups)
	if err != nil {
		t.Fatalf("fanOutRecycleBinVisibility: %v", err)
	}
	if len(items) != 1 {
		t.Errorf("Account rows should survive Opportunity's failure; got %d items", len(items))
	}
	if len(failures) != 1 || failures[0].ObjectAPIName != "Opportunity" {
		t.Fatalf("expected 1 failure naming Opportunity; got %+v", failures)
	}
	if failures[0].ErrorCode != string(contract.RecycleBinFailureCodeForbidden) {
		t.Errorf("failure code = %q, want %q", failures[0].ErrorCode, contract.RecycleBinFailureCodeForbidden)
	}
}

func TestFanOutVisibility_FailuresFollowInputOrderNotCompletionOrder(t *testing.T) {
	firstStarted := make(chan struct{})
	secondCompleted := make(chan struct{})
	releaseFirst := make(chan struct{})
	stub := &fanoutQueryStub{
		errs: map[string]error{
			"Alpha": apierr.Internal(errors.New("alpha failed")),
			"Zulu":  apierr.Internal(errors.New("zulu failed")),
		},
		hooks: map[string]func(context.Context) error{
			"Alpha": func(context.Context) error {
				close(firstStarted)
				<-releaseFirst
				return nil
			},
			"Zulu": func(context.Context) error {
				<-firstStarted
				close(secondCompleted)
				return nil
			},
		},
	}
	go func() {
		<-secondCompleted
		close(releaseFirst)
	}()
	ps := makeFanoutFixture(t, stub)
	groups := []recycleBinObjectGroup{
		singleObjectGroup("Alpha", "Name", tombstone("a1", "Alpha", "A", "Alice", "2026-05-03T10:00:00Z")),
		singleObjectGroup("Zulu", "Name", tombstone("z1", "Zulu", "Z", "Alice", "2026-05-02T10:00:00Z")),
	}

	_, failures, err := ps.fanOutRecycleBinVisibility(context.Background(), &models.UserSession{ID: "u1"}, RecycleBinScopeMine, groups)
	if err != nil {
		t.Fatalf("fanOutRecycleBinVisibility: %v", err)
	}
	if len(failures) != 2 || failures[0].ObjectAPIName != "Alpha" || failures[1].ObjectAPIName != "Zulu" {
		t.Fatalf("failures = %+v, want deterministic Alpha, Zulu order", failures)
	}
}

func TestFanOutVisibility_CancellationFailsWholeOperation(t *testing.T) {
	started := make(chan struct{})
	stub := &fanoutQueryStub{hooks: map[string]func(context.Context) error{
		"Account": func(ctx context.Context) error {
			close(started)
			<-ctx.Done()
			return ctx.Err()
		},
	}}
	ps := makeFanoutFixture(t, stub)
	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		<-started
		cancel()
	}()

	items, failures, err := ps.fanOutRecycleBinVisibility(ctx, &models.UserSession{ID: "u1"}, RecycleBinScopeMine, []recycleBinObjectGroup{
		singleObjectGroup("Account", "Name", tombstone("a1", "Account", "Acme", "Alice", "2026-05-03T10:00:00Z")),
	})
	if !errors.Is(err, context.Canceled) || items != nil || failures != nil {
		t.Fatalf("canceled fanout = items=%+v failures=%+v err=%v, want complete cancellation", items, failures, err)
	}
}

func TestFanOutVisibility_RawErrorMessageIsSanitized(t *testing.T) {
	// A non-apierr error (raw DB driver text) must not propagate verbatim
	// into failedObjects[].message; only the errorCode survives.
	stub := &fanoutQueryStub{
		errs: map[string]error{
			"Opportunity": errors.New("pq: relation \"_internal_shadow_42\" does not exist (driver internals)"),
		},
	}
	ps := makeFanoutFixture(t, stub)
	groups := []recycleBinObjectGroup{
		singleObjectGroup("Opportunity", "Name", tombstone("o1", "Opportunity", "Deal", "Alice", "2026-05-02T10:00:00Z")),
	}

	_, failures, err := ps.fanOutRecycleBinVisibility(context.Background(), &models.UserSession{ID: "u1"}, RecycleBinScopeMine, groups)
	if err != nil {
		t.Fatalf("fanOutRecycleBinVisibility: %v", err)
	}
	if len(failures) != 1 {
		t.Fatalf("expected 1 failure; got %d", len(failures))
	}
	if failures[0].Message != "" {
		t.Errorf("raw (non-apierr) message must be sanitized to empty; got %q", failures[0].Message)
	}
	if failures[0].ErrorCode == "" {
		t.Errorf("ErrorCode should still be populated; got empty")
	}
	if failures[0].ErrorCode != string(contract.RecycleBinFailureCodeUnknown) {
		t.Errorf("raw error code = %q, want %q", failures[0].ErrorCode, contract.RecycleBinFailureCodeUnknown)
	}
}

func TestSanitizedFailureMessage_TrustsAPIErrorMessages(t *testing.T) {
	got := sanitizedFailureMessage(apierr.Forbidden("no FLS on Opportunity"))
	if got != "no FLS on Opportunity" {
		t.Errorf("apierr message should pass through; got %q", got)
	}
	if got := sanitizedFailureMessage(nil); got != "" {
		t.Errorf("nil error -> empty message; got %q", got)
	}
}

// --- ordering + cap ---

func TestSortTombstonesByDeletedDateDesc(t *testing.T) {
	toms := []recycleBinTombstone{
		tombstone("a", "Account", "A", "Alice", "2026-05-01T00:00:00Z"),
		tombstone("b", "Account", "B", "Alice", "2026-05-03T00:00:00Z"),
		tombstone("c", "Account", "C", "Alice", "2026-05-02T00:00:00Z"),
	}
	sortTombstonesByDeletedDateDesc(toms)
	if toms[0].recordID != "b" || toms[1].recordID != "c" || toms[2].recordID != "a" {
		t.Fatalf("expected newest-deleted-first order b,c,a; got %s,%s,%s", toms[0].recordID, toms[1].recordID, toms[2].recordID)
	}
}

func TestSortItemsByDeletedDateDesc(t *testing.T) {
	items := []RecycleBinItem{
		{ID: "a", DeletedDate: "2026-05-01T00:00:00Z"},
		{ID: "b", DeletedDate: "2026-05-03T00:00:00Z"},
	}
	sortItemsByDeletedDateDesc(items)
	if items[0].ID != "b" {
		t.Fatalf("expected newest-deleted-first; got %s first", items[0].ID)
	}
}

func TestRecycleBinAggregateLimitIsPublishedValue(t *testing.T) {
	if recycleBinAggregateLimit != 200 {
		t.Errorf("recycleBinAggregateLimit drifted from 200; FE/design assume the published value")
	}
}

// --- name-field resolution ---

func TestNameFieldFor_FirstNameFieldWins(t *testing.T) {
	meta := &models.ObjectMetadata{
		APIName: "Custom",
		Fields: []models.FieldMetadata{
			{APIName: "Id", IsNameField: false},
			{APIName: "Title", IsNameField: true},
			{APIName: "OtherName", IsNameField: true},
		},
	}
	if got := nameFieldFor(meta); got != "Title" {
		t.Errorf("nameFieldFor = %q, want %q", got, "Title")
	}
}

func TestNameFieldFor_NoneReturnsEmpty(t *testing.T) {
	meta := &models.ObjectMetadata{APIName: "Custom"}
	if got := nameFieldFor(meta); got != "" {
		t.Errorf("nameFieldFor = %q, want empty", got)
	}
}

// --- helpers / small utilities ---

func lastSpec(t *testing.T, stub *fanoutQueryStub) queryspec.QuerySpec {
	t.Helper()
	stub.capturesMu.Lock()
	defer stub.capturesMu.Unlock()
	if len(stub.specs) == 0 {
		t.Fatal("expected at least one captured QueryWithSpec call; got 0")
	}
	return stub.specs[len(stub.specs)-1]
}

func lastQueryContext(t *testing.T, stub *fanoutQueryStub) context.Context {
	t.Helper()
	stub.capturesMu.Lock()
	defer stub.capturesMu.Unlock()
	if len(stub.contexts) == 0 {
		t.Fatal("expected at least one captured QueryWithSpec context; got 0")
	}
	return stub.contexts[len(stub.contexts)-1]
}

func findCriterion(t *testing.T, spec queryspec.QuerySpec, field string) queryspec.QueryCriterion {
	t.Helper()
	for _, c := range spec.Filter.Criteria() {
		if c.Field == field {
			return c
		}
	}
	t.Fatalf("no criterion for field %q in spec filter", field)
	return queryspec.QueryCriterion{}
}

func containsField(haystack []string, needle string) bool {
	for _, s := range haystack {
		if s == needle {
			return true
		}
	}
	return false
}

func mustAPIErrStatus(t *testing.T, err error, wantStatus int) {
	t.Helper()
	if err == nil {
		t.Fatalf("expected *apierr.Error with status %d, got nil", wantStatus)
	}
	var apiErr *apierr.Error
	if !errors.As(err, &apiErr) {
		t.Fatalf("expected *apierr.Error, got %T: %v", err, err)
	}
	if apiErr.Status != wantStatus {
		t.Errorf("Status = %d, want %d", apiErr.Status, wantStatus)
	}
}

// --- tiny synchronization helper (no third-party deps) ---

// TestGetRecycleBinItems_RendersRealTimeValuesAsRFC3339 exercises the production
// reality the string fixtures masked: with parseTime=True the driver hands
// DATETIME columns back as real time.Time, and the wire timestamps must be
// RFC3339 (matching the standard query/retrieve surfaces) — not Go's default
// "2006-01-02 15:04:05 +0000 UTC" layout that stringValue would emit.
func TestGetRecycleBinItems_RendersRealTimeValuesAsRFC3339(t *testing.T) {
	const obj = "Account"
	deletedAt := time.Date(2026, 5, 3, 10, 15, 30, 0, time.UTC)
	createdAt := time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC)
	modifiedAt := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)

	tomb := models.SObject{
		constants.FieldSysRecycleBin_ID: "rb-rec-1",
		constants.FieldRecordID:         "rec-1",
		constants.FieldObjectAPIName:    obj,
		constants.FieldRecordName:       "snapshot",
		constants.FieldDeletedBy:        "Alice",
		constants.FieldDeletedDate:      deletedAt, // real time.Time, NOT a preformatted string
	}
	live := models.SObject{
		constants.FieldID:               "rec-1",
		constants.FieldName:             "Acme",
		constants.FieldIsDeleted:        true,
		constants.FieldCreatedDate:      createdAt,
		constants.FieldLastModifiedDate: modifiedAt,
	}
	repo := &fanoutRepoStub{byUserPages: map[string][][]models.SObject{"u1": {{tomb}}}}
	stub := &fanoutQueryStub{results: map[string][]models.SObject{obj: {live}}}
	ps := &PersistenceService{repo: repo, metadata: makeFanoutMetadataService(t, fanoutObjectMetadata(obj)), querySvc: stub}

	res, err := ps.GetRecycleBinItems(context.Background(), &models.UserSession{ID: "u1", Name: "Alice"}, RecycleBinScopeMine)
	if err != nil {
		t.Fatalf("GetRecycleBinItems: %v", err)
	}
	if len(res.Items) != 1 {
		t.Fatalf("items = %d, want 1", len(res.Items))
	}
	it := res.Items[0]
	rfc3339 := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$`)
	for name, got := range map[string]string{"DeletedDate": it.DeletedDate, "CreatedDate": it.CreatedDate, "LastModifiedDate": it.LastModifiedDate} {
		if !rfc3339.MatchString(got) {
			t.Errorf("%s = %q, want RFC3339 wire shape (real time.Time must not leak Go's default String() layout)", name, got)
		}
	}
	if it.DeletedDate != "2026-05-03T10:15:30Z" {
		t.Errorf("DeletedDate = %q, want 2026-05-03T10:15:30Z", it.DeletedDate)
	}
	if it.CreatedDate != "2026-04-01T09:00:00Z" {
		t.Errorf("CreatedDate = %q, want 2026-04-01T09:00:00Z", it.CreatedDate)
	}
}

// TestGetRecycleBinItems_CapIsDeterministicForSameSecondDeletes proves the
// post-merge 200-row cap is stable when many records share a same-second
// DeletedDate across multiple objects. Without the (DeletedDate, tombstoneId)
// tie-break the cap depends on goroutine-completion order and can drop newer
// tombstones arbitrarily across refreshes.
func TestGetRecycleBinItems_CapIsDeterministicForSameSecondDeletes(t *testing.T) {
	const sameSecond = "2026-05-03T10:00:00Z"
	const total = 250 // one page of >200 visible survivors so the cap truncates
	objs := []string{"Account", "Contact", "Lead"}

	all := make([]models.SObject, 0, total)
	results := map[string][]models.SObject{}
	for i := 0; i < total; i++ {
		obj := objs[i%len(objs)]
		rid := fmt.Sprintf("rec-%04d", i) // zero-padded: lexical id order == numeric
		all = append(all, tombstoneRow(rid, obj, "snap-"+rid, "Alice", sameSecond))
		results[obj] = append(results[obj], liveDeletedRow(rid, "live-"+rid, "2026-04-01T00:00:00Z", "2026-05-10T12:00:00Z"))
	}
	// One page carrying all 250 same-second candidates (the stub ignores the page
	// limit). All are visible, so 250 survivors reach the post-merge sort and the
	// 200-cap must truncate by the (DeletedDate, tombstoneId) keyset.
	pages := [][]models.SObject{all}
	meta := makeFanoutMetadataService(t, fanoutObjectMetadata("Account"), fanoutObjectMetadata("Contact"), fanoutObjectMetadata("Lead"))

	run := func() []string {
		repo := &fanoutRepoStub{byUserPages: map[string][][]models.SObject{"u1": pages}}
		ps := &PersistenceService{repo: repo, metadata: meta, querySvc: &fanoutQueryStub{results: results}}
		res, err := ps.GetRecycleBinItems(context.Background(), &models.UserSession{ID: "u1", Name: "Alice"}, RecycleBinScopeMine)
		if err != nil {
			t.Fatalf("GetRecycleBinItems: %v", err)
		}
		ids := make([]string, len(res.Items))
		for i, it := range res.Items {
			ids[i] = it.RecordID
		}
		return ids
	}

	first := run()
	if len(first) != recycleBinAggregateLimit {
		t.Fatalf("items = %d, want cap %d", len(first), recycleBinAggregateLimit)
	}
	// The cap must select the top-200 by tombstone Id (DeletedDate ties broken by
	// Id DESC), i.e. the 200 highest ordinals among the 250 scanned candidates.
	want := map[string]bool{}
	for i := total - recycleBinAggregateLimit; i < total; i++ {
		want[fmt.Sprintf("rec-%04d", i)] = true
	}
	for _, id := range first {
		if !want[id] {
			t.Fatalf("cap returned %q, which is not in the deterministic top-%d by Id — tie-break dropped", id, recycleBinAggregateLimit)
		}
	}
	// Deterministic across runs: goroutine-completion order must not move the cap.
	for r := 0; r < 4; r++ {
		if got := run(); strings.Join(got, ",") != strings.Join(first, ",") {
			t.Fatalf("cap is nondeterministic across runs for same-second deletes (missing tie-break)")
		}
	}
}

// TestLoadDeleteEventAuthorityPage_CursorIsDBDateTimeForSecondPage exercises the
// keyset cursor across a second tombstone page with REAL time.Time DeletedDate
// values (what the driver hands back, parseTime=True) — the path the prior
// string fixtures masked. The cursor is bound into a DATETIME predicate, so it
// must be a DB DATETIME literal, not Go's time.Time.String() ("… +0000 UTC")
// nor RFC3339 ("T…Z"), either of which mis-binds and can skip/repeat/lose pages.
func TestLoadDeleteEventAuthorityPage_CursorIsDBDateTimeForSecondPage(t *testing.T) {
	deletedAt := time.Date(2026, 6, 2, 15, 1, 14, 0, time.UTC)
	timeRow := func(rid string) models.SObject {
		return models.SObject{
			constants.FieldSysRecycleBin_ID: "rb-" + rid,
			constants.FieldRecordID:         rid,
			constants.FieldObjectAPIName:    "Account",
			constants.FieldRecordName:       "snap-" + rid,
			constants.FieldDeletedBy:        "Alice",
			constants.FieldDeletedDate:      deletedAt, // real time.Time, NOT a preformatted string
		}
	}
	repo := &fanoutRepoStub{byUserPages: map[string][][]models.SObject{
		"u1": {{timeRow("rec-1")}, {timeRow("rec-2")}}, // scope=mine keys by the stable DeletedById (#1750)
	}}
	ps := &PersistenceService{repo: repo}
	user := &models.UserSession{ID: "u1", Name: "Alice"}

	// Page 1 produces the cursor.
	_, next, _, _, err := ps.loadDeleteEventAuthorityPage(context.Background(), user, RecycleBinScopeMine, recycleBinPageCursor{}, recycleBinAggregateLimit)
	if err != nil {
		t.Fatalf("page 1: %v", err)
	}
	dbDT := regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$`)
	if !dbDT.MatchString(next.deletedDate) {
		t.Fatalf("cursor.deletedDate = %q, want a DB DATETIME literal (no 'T'/'Z'/'+0000 UTC')", next.deletedDate)
	}

	// Page 2 must receive that DB-native cursor value as the keyset bound.
	if _, _, _, _, err = ps.loadDeleteEventAuthorityPage(context.Background(), user, RecycleBinScopeMine, next, recycleBinAggregateLimit); err != nil {
		t.Fatalf("page 2: %v", err)
	}
	if len(repo.pageCalls) != 2 {
		t.Fatalf("page calls = %d, want 2", len(repo.pageCalls))
	}
	if got := repo.pageCalls[1].afterDeletedDate; !dbDT.MatchString(got) {
		t.Fatalf("page-2 cursor bound %q into the DATETIME predicate, want a DB DATETIME literal", got)
	}
}

func (*fanoutPermRepo) GetManualShareRecordIDs(context.Context, string, string) ([]string, error) {
	return nil, nil
}
func (*fanoutPermRepo) GetTeamMemberRecordIDs(context.Context, string, string) ([]string, error) {
	return nil, nil
}
func (*fanoutPermRepo) GetGroupMemberIDs(context.Context, []string) ([]string, error) {
	return nil, nil
}
