package records

import (
	"context"
	"encoding/json"
	stderrors "errors"
	"net/http"
	"reflect"
	"strings"
	"testing"

	eventFeature "github.com/nexuscrm/events"
	"github.com/nexuscrm/kernel/pkg/apierr"
	appErrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/formula"
	"github.com/nexuscrm/kernel/pkg/logging"
	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/kernel/pkg/recordlocks"
	metadataFeature "github.com/nexuscrm/metadata"
	"github.com/nexuscrm/permissions"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/contract"
	sharedEvents "github.com/nexuscrm/shared/pkg/events"
	"github.com/nexuscrm/shared/pkg/models"
)

type recycleBinRestoreRepoStub struct {
	ports.RecordRepository

	tombstone     models.SObject
	tombstones    map[string]models.SObject
	records       map[string]models.SObject
	parents       map[string]models.SObject
	queueSupports map[string]bool

	updatedObject string
	updatedID     string
	updated       models.SObject
	updateCalls   []relationshipDeleteUpdate
	deletedObject string
	deletedID     string
	deleteCalls   []string
	lookupLog     []string
}

func (r *recycleBinRestoreRepoStub) FindRecycleBinEntry(_ context.Context, recordID string) (models.SObject, error) {
	r.lookupLog = append(r.lookupLog, "find-bin:"+recordID)
	if r.tombstones != nil {
		if tombstone := r.tombstones[strings.ToLower(recordID)]; tombstone != nil {
			return cloneSObject(tombstone), nil
		}
	}
	if r.tombstone == nil || !strings.EqualFold(stringValue(r.tombstone[constants.FieldRecordID]), recordID) {
		return nil, nil
	}
	return cloneSObject(r.tombstone), nil
}

func (r *recycleBinRestoreRepoStub) FindRecycleBinEntriesByDeleteOperation(_ context.Context, operationID string) ([]models.SObject, error) {
	var out []models.SObject
	for _, tombstone := range r.tombstones {
		if strings.EqualFold(stringValue(tombstone[constants.FieldSysRecycleBin_DeleteOperationID]), operationID) {
			out = append(out, cloneSObject(tombstone))
		}
	}
	if r.tombstone != nil && strings.EqualFold(stringValue(r.tombstone[constants.FieldSysRecycleBin_DeleteOperationID]), operationID) {
		out = append(out, cloneSObject(r.tombstone))
	}
	return out, nil
}

func (r *recycleBinRestoreRepoStub) FindAny(_ context.Context, tableName string, id string) (models.SObject, error) {
	return cloneSObject(r.records[strings.ToLower(tableName)+"|"+id]), nil
}

func (r *recycleBinRestoreRepoStub) GetLock(_ context.Context, tableName string, id string) (models.SObject, error) {
	r.lookupLog = append(r.lookupLog, "lock:"+tableName+":"+id)
	if strings.EqualFold(tableName, constants.TableRecycleBin) {
		for _, tombstone := range r.tombstones {
			if strings.EqualFold(stringValue(tombstone[constants.FieldSysRecycleBin_ID]), id) {
				return cloneSObject(tombstone), nil
			}
		}
		if r.tombstone == nil || !strings.EqualFold(stringValue(r.tombstone[constants.FieldSysRecycleBin_ID]), id) {
			return nil, nil
		}
		return cloneSObject(r.tombstone), nil
	}
	key := strings.ToLower(tableName) + "|" + id
	if row := r.records[key]; row != nil {
		return cloneSObject(row), nil
	}
	return cloneSObject(r.parents[key]), nil
}

func (r *recycleBinRestoreRepoStub) GetDeletedChildren(_ context.Context, childTable string, foreignKey string, parentID string) ([]models.SObject, error) {
	var out []models.SObject
	prefix := strings.ToLower(childTable) + "|"
	for key, record := range r.records {
		if !strings.HasPrefix(key, prefix) {
			continue
		}
		if !isGuardFieldTruthy(record[constants.FieldIsDeleted]) {
			continue
		}
		if !strings.EqualFold(strings.TrimSpace(stringValue(record[foreignKey])), parentID) {
			continue
		}
		out = append(out, cloneSObject(record))
	}
	return out, nil
}

func (r *recycleBinRestoreRepoStub) FindOne(_ context.Context, tableName string, id string) (models.SObject, error) {
	key := strings.ToLower(tableName) + "|" + id
	row := r.records[key]
	if row == nil {
		row = r.parents[key]
	}
	if row == nil || isGuardFieldTruthy(row[constants.FieldIsDeleted]) {
		return nil, nil
	}
	return cloneSObject(row), nil
}

func (r *recycleBinRestoreRepoStub) Exists(ctx context.Context, tableName string, id string) (bool, error) {
	row, err := r.FindOne(ctx, tableName, id)
	return row != nil, err
}

func (r *recycleBinRestoreRepoStub) Update(_ context.Context, tableName string, id string, updates models.SObject) error {
	r.updatedObject = tableName
	r.updatedID = id
	r.updated = cloneSObject(updates)
	r.updateCalls = append(r.updateCalls, relationshipDeleteUpdate{table: tableName, id: id, updates: cloneSObject(updates)})
	key := strings.ToLower(tableName) + "|" + id
	if row := r.records[key]; row != nil {
		for field, value := range updates {
			row[field] = value
		}
	}
	if row := r.parents[key]; row != nil {
		for field, value := range updates {
			row[field] = value
		}
	}
	return nil
}

func (r *recycleBinRestoreRepoStub) Insert(_ context.Context, _ string, _ models.SObject) error {
	return nil
}

func (r *recycleBinRestoreRepoStub) PhysicalDelete(_ context.Context, tableName string, id string) error {
	r.deletedObject = tableName
	r.deletedID = id
	r.deleteCalls = append(r.deleteCalls, tableName+"|"+id)
	if strings.EqualFold(tableName, constants.TableRecycleBin) {
		for recordID, tombstone := range r.tombstones {
			if strings.EqualFold(stringValue(tombstone[constants.FieldSysRecycleBin_ID]), id) {
				delete(r.tombstones, recordID)
				break
			}
		}
	}
	return nil
}

func (r *recycleBinRestoreRepoStub) CheckUniqueness(_ context.Context, schema *models.ObjectMetadata, fieldName string, value interface{}, excludeID string) (bool, error) {
	if schema == nil {
		return false, nil
	}
	prefix := strings.ToLower(schema.APIName) + "|"
	for key, row := range r.records {
		if !strings.HasPrefix(key, prefix) {
			continue
		}
		id, _ := row[constants.FieldID].(string)
		if excludeID != "" && strings.EqualFold(id, excludeID) {
			continue
		}
		if reflect.DeepEqual(row[fieldName], value) {
			return true, nil
		}
	}
	return false, nil
}

func (r *recycleBinRestoreRepoStub) CheckQueueSupport(_ context.Context, queueID string, objectType string) (bool, error) {
	if r.queueSupports == nil {
		return false, nil
	}
	return r.queueSupports[strings.ToLower(queueID)+"|"+strings.ToLower(objectType)], nil
}

func (r *recycleBinRestoreRepoStub) LockExclusiveSiblingResetRows(_ context.Context, schema *models.ObjectMetadata, fieldAPIName, partitionFieldAPIName string, partitionValue interface{}, winnerID string) ([]models.SObject, error) {
	var out []models.SObject
	prefix := strings.ToLower(schema.APIName) + "|"
	for key, row := range r.records {
		if !strings.HasPrefix(key, prefix) {
			continue
		}
		if isGuardFieldTruthy(row[constants.FieldIsDeleted]) {
			continue
		}
		if strings.EqualFold(stringValue(row[constants.FieldID]), winnerID) {
			continue
		}
		if !exclusiveSiblingTruthy(row[fieldAPIName]) {
			continue
		}
		if !strings.EqualFold(stringValue(row[partitionFieldAPIName]), stringValue(partitionValue)) {
			continue
		}
		out = append(out, cloneSObject(row))
	}
	return out, nil
}

type restoreActiveProjectionPort struct {
	repo                *recycleBinRestoreRepoStub
	liveCalls           int
	includeDeletedCalls int
}

func (p *restoreActiveProjectionPort) QueryActiveProjectionRow(_ context.Context, _ *models.UserSession, _ string, _ string, includeDeleted bool, _ []string) (models.SObject, error) {
	if includeDeleted {
		p.includeDeletedCalls++
		return models.SObject{"RestoredFormula__c": "visible-before-restore"}, nil
	}
	p.liveCalls++
	if p.repo == nil || p.repo.updated == nil {
		return nil, nil
	}
	return models.SObject{"RestoredFormula__c": "visible-after-restore"}, nil
}

type restorePermissionCacheSpy struct {
	invalidatedUsers []string
	invalidatedAll   int
}

func (c *restorePermissionCacheSpy) GetObjectPermission(userID, profileID, objectAPIName string) (*models.ObjectPermissions, bool) {
	return nil, false
}

func (c *restorePermissionCacheSpy) GetFieldPermission(userID, profileID, objectAPIName, fieldAPIName string) (*models.FieldPermissions, bool) {
	return nil, false
}

func (c *restorePermissionCacheSpy) HasObjectPermsLoaded(userID, profileID string) bool {
	return false
}

func (c *restorePermissionCacheSpy) HasFieldPermsLoaded(userID, profileID string) bool {
	return false
}

func (c *restorePermissionCacheSpy) InvalidateUser(userID string) {
	c.invalidatedUsers = append(c.invalidatedUsers, userID)
}

func (c *restorePermissionCacheSpy) Invalidate() {
	c.invalidatedAll++
}

func TestRestoreRecordRestoresOwnedTombstone(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema("Account")
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	ps := newRecycleBinRestoreService(t, repo, true, schema)

	if err := ps.RestoreRecord(context.Background(), "rec-1", user); err != nil {
		t.Fatalf("RestoreRecord: %v", err)
	}
	if repo.updatedObject != "Account" || repo.updatedID != "rec-1" {
		t.Fatalf("updated target = %s/%s, want Account/rec-1", repo.updatedObject, repo.updatedID)
	}
	if got, ok := repo.updated[constants.FieldIsDeleted].(bool); !ok || got {
		t.Fatalf("updated IsDeleted = %#v, want false", repo.updated[constants.FieldIsDeleted])
	}
	if repo.deletedObject != constants.TableRecycleBin || repo.deletedID != "rb-rec-1" {
		t.Fatalf("deleted tombstone = %s/%s, want %s/rb-rec-1", repo.deletedObject, repo.deletedID, constants.TableRecycleBin)
	}
}

func TestRestoreRecordRunsUpdateLifecycle(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema("Account")
	schema.Fields = append(schema.Fields, models.FieldMetadata{
		APIName:    "LifecycleField__c",
		Type:       constants.FieldTypeString,
		Createable: true,
		Updateable: true,
	})
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	ps := newRecycleBinRestoreService(t, repo, true, schema)
	ps.eventBus = mutatingRecordEventBus{mutate: func(eventType sharedEvents.EventType, record models.SObject) {
		if eventType == sharedEvents.RecordBeforeUpdate {
			record["LifecycleField__c"] = "flowed"
		}
	}}
	outboxRepo := &stubOutboxRepo{}
	ps.outbox = eventFeature.NewOutboxService(outboxRepo, &eventFeature.NoOpEventBus{}, inlineTxManager{})

	if err := ps.RestoreRecord(context.Background(), "rec-1", user); err != nil {
		t.Fatalf("RestoreRecord: %v", err)
	}
	if got := repo.updated["LifecycleField__c"]; got != "flowed" {
		t.Fatalf("LifecycleField__c update = %#v, want flowed", got)
	}
	if outboxRepo.enqueueCalls != 1 {
		t.Fatalf("RecordUpdated outbox enqueue calls = %d, want 1", outboxRepo.enqueueCalls)
	}
}

func TestRestoreRecordStagesDeletedOldActiveProjection(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema("Account")
	schema.Fields = append(schema.Fields, restoreActiveFormulaField("RestoredFormula__c"))
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	ps := newRecycleBinRestoreService(t, repo, true, schema)
	projectionPort := &restoreActiveProjectionPort{repo: repo}
	ps.activeFormulaProjector = newActiveFormulaProjector(projectionPort)
	outboxRepo := &stubOutboxRepo{}
	ps.outbox = eventFeature.NewOutboxService(outboxRepo, &eventFeature.NoOpEventBus{}, inlineTxManager{})

	if err := ps.RestoreRecord(context.Background(), "rec-1", user); err != nil {
		t.Fatalf("RestoreRecord: %v", err)
	}
	if projectionPort.liveCalls != 1 || projectionPort.includeDeletedCalls != 1 {
		t.Fatalf("active projection calls live=%d includeDeleted=%d, want live=1 includeDeleted=1", projectionPort.liveCalls, projectionPort.includeDeletedCalls)
	}
	if outboxRepo.enqueueCalls != 1 {
		t.Fatalf("RecordUpdated outbox enqueue calls = %d, want 1", outboxRepo.enqueueCalls)
	}
	newSnap := decodeProjectionMetadata(t, outboxRepo.lastEnvelope.InternalMetadata["active_projection_record"])
	if got := newSnap["RestoredFormula__c"]; got != "visible-after-restore" {
		t.Fatalf("active_projection_record RestoredFormula__c = %#v, want visible-after-restore", got)
	}
	oldSnap := decodeProjectionMetadata(t, outboxRepo.lastEnvelope.InternalMetadata["active_projection_old_record"])
	if got := oldSnap["RestoredFormula__c"]; got != "visible-before-restore" {
		t.Fatalf("active_projection_old_record RestoredFormula__c = %#v, want visible-before-restore", got)
	}
}

func decodeProjectionMetadata(t *testing.T, raw string) models.SObject {
	t.Helper()
	if raw == "" {
		t.Fatal("missing active projection metadata")
	}
	var snap models.SObject
	if err := json.Unmarshal([]byte(raw), &snap); err != nil {
		t.Fatalf("decode active projection metadata: %v", err)
	}
	return snap
}

func TestRestoreRecordRetriesWhenBeforeSaveFlowIntroducesLookupParent(t *testing.T) {
	user := recordEntrypointUser()
	parentSchema := restoreObjectSchema("Parent__c")
	childSchema := restoreObjectSchema("Child__c")
	childSchema.Fields = append(childSchema.Fields, models.FieldMetadata{
		APIName:     "Parent__c",
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{"Parent__c"},
		Createable:  true,
		Updateable:  true,
	})
	makeFieldsMap(childSchema)
	repo := newRecycleBinRestoreRepo("Child__c", "child-1", user.ID)
	repo.parents = map[string]models.SObject{
		"parent__c|parent-2": {
			constants.FieldID:        "parent-2",
			constants.FieldName:      "Parent",
			constants.FieldOwnerID:   user.ID,
			constants.FieldIsDeleted: false,
		},
	}
	ps := newRecycleBinRestoreService(t, repo, true, parentSchema, childSchema)
	ps.eventBus = mutatingRecordEventBus{mutate: func(eventType sharedEvents.EventType, record models.SObject) {
		if eventType == sharedEvents.RecordBeforeUpdate {
			record["Parent__c"] = "parent-2"
		}
	}}

	if err := ps.RestoreRecord(context.Background(), "child-1", user); err != nil {
		t.Fatalf("RestoreRecord: %v", err)
	}
	if got := repo.updated["Parent__c"]; got != "parent-2" {
		t.Fatalf("Parent__c update = %#v, want parent-2", got)
	}
}

func TestRestoreRecordAllowsBeforeSaveFlowToRepairRequiredParent(t *testing.T) {
	user := recordEntrypointUser()
	parentSchema := restoreObjectSchema("Parent__c")
	childSchema := restoreObjectSchema("Child__c")
	childSchema.Fields = append(childSchema.Fields, models.FieldMetadata{
		APIName:     "Parent__c",
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{"Parent__c"},
		Required:    true,
		Createable:  true,
		Updateable:  true,
	})
	makeFieldsMap(childSchema)
	repo := newRecycleBinRestoreRepo("Child__c", "child-1", user.ID)
	repo.records["child__c|child-1"]["Parent__c"] = "parent-deleted"
	repo.parents = map[string]models.SObject{
		"parent__c|parent-deleted": {
			constants.FieldID:        "parent-deleted",
			constants.FieldName:      "Deleted Parent",
			constants.FieldOwnerID:   user.ID,
			constants.FieldIsDeleted: true,
		},
		"parent__c|parent-2": {
			constants.FieldID:        "parent-2",
			constants.FieldName:      "Parent",
			constants.FieldOwnerID:   user.ID,
			constants.FieldIsDeleted: false,
		},
	}
	ps := newRecycleBinRestoreService(t, repo, true, parentSchema, childSchema)
	ps.eventBus = mutatingRecordEventBus{mutate: func(eventType sharedEvents.EventType, record models.SObject) {
		if eventType == sharedEvents.RecordBeforeUpdate {
			record["Parent__c"] = "parent-2"
		}
	}}

	if err := ps.RestoreRecord(context.Background(), "child-1", user); err != nil {
		t.Fatalf("RestoreRecord: %v", err)
	}
	if got := repo.updated["Parent__c"]; got != "parent-2" {
		t.Fatalf("Parent__c update = %#v, want parent-2", got)
	}
}

func TestRestoreRecordNonRootLineageLetsBeforeSaveRepairMissingHardDeletedAncestor(t *testing.T) {
	user := recordEntrypointUser()
	parentSchema := restoreObjectSchema("Parent__c")
	childSchema := restoreObjectSchema("Child__c")
	cascade := constants.DeleteRuleCascade
	childSchema.Fields = append(childSchema.Fields, models.FieldMetadata{
		APIName:     "Parent__c",
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{"Parent__c"},
		DeleteRule:  &cascade,
		Createable:  true,
		Updateable:  true,
	})
	makeFieldsMap(childSchema)

	repo := newRecycleBinRestoreRepo("Child__c", "child-1", user.ID)
	operationID := "delete-op-1"
	repo.tombstone = nil
	repo.tombstones = map[string]models.SObject{
		"parent-1": restoreTombstone("Parent__c", "parent-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:       operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeDepth:            0,
		}),
		"child-1": restoreTombstone("Child__c", "child-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:          operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName:    "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:         "parent-1",
			constants.FieldSysRecycleBin_CascadeParentObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_CascadeParentRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeFieldAPIName:        "Parent__c",
			constants.FieldSysRecycleBin_CascadeDepth:               1,
		}),
	}
	repo.records = map[string]models.SObject{
		"child__c|child-1": {
			constants.FieldID:        "child-1",
			constants.FieldName:      "Deleted child",
			constants.FieldOwnerID:   user.ID,
			constants.FieldIsDeleted: true,
			"Parent__c":              "parent-1",
		},
	}
	repo.parents = map[string]models.SObject{
		"parent__c|parent-2": {
			constants.FieldID:        "parent-2",
			constants.FieldName:      "Replacement Parent",
			constants.FieldOwnerID:   user.ID,
			constants.FieldIsDeleted: false,
		},
	}
	ps := newRecycleBinRestoreService(t, repo, true, parentSchema, childSchema)
	ps.eventBus = mutatingRecordEventBus{mutate: func(eventType sharedEvents.EventType, record models.SObject) {
		if eventType == sharedEvents.RecordBeforeUpdate {
			record["Parent__c"] = "parent-2"
		}
	}}

	if err := ps.RestoreRecord(context.Background(), "child-1", user); err != nil {
		t.Fatalf("RestoreRecord non-root lineage with repaired ancestor: %v; lookups=%#v", err, repo.lookupLog)
	}
	if got := repo.updated["Parent__c"]; got != "parent-2" {
		t.Fatalf("Parent__c update = %#v, want parent-2", got)
	}
	for _, entry := range repo.lookupLog {
		if entry == "lock:Parent__c:parent-1" {
			t.Fatalf("RestoreRecord prelocked hard-deleted historical ancestor: %#v", repo.lookupLog)
		}
	}
}

func TestRestoreRecordHidesNonOwnedTombstoneWithoutViewAll(t *testing.T) {
	schema := restoreObjectSchema("Account")
	repo := newRecycleBinRestoreRepo("Account", "rec-1", "005-other")
	ps := newRecycleBinRestoreService(t, repo, true, schema)

	err := ps.RestoreRecord(context.Background(), "rec-1", recordEntrypointUser())
	if statusOfAPIError(t, err) != http.StatusNotFound {
		t.Fatalf("RestoreRecord error = %v, want 404", err)
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated record despite hidden tombstone: %#v", repo.updated)
	}
}

func TestRestoreRecordRequiresObjectEdit(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema("Account")
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	ps := newRecycleBinRestoreService(t, repo, false, schema)

	err := ps.RestoreRecord(context.Background(), "rec-1", user)
	if statusOfAPIError(t, err) != http.StatusForbidden {
		t.Fatalf("RestoreRecord error = %v, want 403", err)
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated record without object Edit: %#v", repo.updated)
	}
}

func TestRestoreRecordUsesUpdateAccessGateForRecordEditDenial(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema("Account")
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	repo.records["account|rec-1"][constants.FieldOwnerID] = "005-other-owner"
	ps := newRecycleBinRestoreService(t, repo, true, schema)

	err := ps.RestoreRecord(context.Background(), "rec-1", user)
	if statusOfAPIError(t, err) != http.StatusNotFound {
		t.Fatalf("RestoreRecord error = %v, want 404", err)
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated record without record Edit access: %#v", repo.updated)
	}
}

func TestRestoreRecordUsesRecordAwareObjectEditPolicy(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema("Account")
	schema.AccessPolicy = &models.ObjectAccessPolicy{
		Mode: "derived",
		ObjectEdit: &models.AccessPolicyExpr{
			Kind:  "recordFieldEqualsCurrentUser",
			Field: constants.FieldOwnerID,
		},
	}
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	repo.records["account|rec-1"][constants.FieldOwnerID] = user.ID
	ps := newRecycleBinRestoreService(t, repo, false, schema)

	if err := ps.RestoreRecord(context.Background(), "rec-1", user); err != nil {
		t.Fatalf("RestoreRecord: %v", err)
	}
	if repo.updatedObject != "Account" || repo.updatedID != "rec-1" {
		t.Fatalf("updated target = %s/%s, want Account/rec-1", repo.updatedObject, repo.updatedID)
	}
}

func TestRestoreRecordDeniesRecordAwareObjectEditPolicyMismatch(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema("Account")
	schema.AccessPolicy = &models.ObjectAccessPolicy{
		Mode: "derived",
		ObjectEdit: &models.AccessPolicyExpr{
			Kind:  "recordFieldEqualsCurrentUser",
			Field: constants.FieldOwnerID,
		},
	}
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	repo.records["account|rec-1"][constants.FieldOwnerID] = "005-other-owner"
	ps := newRecycleBinRestoreService(t, repo, false, schema)

	err := ps.RestoreRecord(context.Background(), "rec-1", user)
	if statusOfAPIError(t, err) != http.StatusNotFound {
		t.Fatalf("RestoreRecord error = %v, want 404", err)
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated record despite denied record-aware Edit: %#v", repo.updated)
	}
}

func TestRestoreRecordProtectedSpecErrorMatchesDeleteGuard(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema(constants.TableProfile)
	repo := newRecycleBinRestoreRepo(constants.TableProfile, "rec-1", user.ID)
	repo.records["profile|rec-1"][constants.FieldProfile_IsSystem] = true
	ps := newRecycleBinRestoreService(t, repo, true, schema)

	err := ps.RestoreRecord(context.Background(), "rec-1", user)
	var apiErr *apierr.Error
	if !stderrors.As(err, &apiErr) {
		t.Fatalf("RestoreRecord error = %T %v, want *apierr.Error", err, err)
	}
	if apiErr.Status != http.StatusForbidden {
		t.Fatalf("RestoreRecord status = %d, want 403", apiErr.Status)
	}
	want := "CANNOT_DELETE_PROTECTED_RECORD: Platform-managed Profile cannot be deleted"
	if apiErr.Message != want {
		t.Fatalf("RestoreRecord message = %q, want %q", apiErr.Message, want)
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated protected record: %#v", repo.updated)
	}
}

func TestRestoreRecordRevalidatesRequiredFullImageFields(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema("Account")
	schema.Fields = append(schema.Fields, models.FieldMetadata{
		APIName:    "Required__c",
		Type:       constants.FieldTypeString,
		Required:   true,
		Createable: true,
		Updateable: true,
	})
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	repo.records["account|rec-1"]["Required__c"] = ""
	ps := newRecycleBinRestoreService(t, repo, true, schema)

	err := ps.RestoreRecord(context.Background(), "rec-1", user)
	if statusOfAPIError(t, err) != http.StatusBadRequest {
		t.Fatalf("RestoreRecord error = %v, want 400", err)
	}
	if !strings.Contains(err.Error(), "Required__c") {
		t.Fatalf("RestoreRecord error = %v, want Required__c validation", err)
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated record with blank required field: %#v", repo.updated)
	}
}

func TestRestoreRecordAllowsStoredRetiredPicklistValue(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema("Account")
	schema.Fields = append(schema.Fields, models.FieldMetadata{
		APIName:    "Status__c",
		Type:       constants.FieldTypePicklist,
		Options:    models.PicklistOptionsFromStrings([]string{"Open"}),
		Createable: true,
		Updateable: true,
	})
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	repo.records["account|rec-1"]["Status__c"] = "Retired"
	ps := newRecycleBinRestoreService(t, repo, true, schema)

	if err := ps.RestoreRecord(context.Background(), "rec-1", user); err != nil {
		t.Fatalf("RestoreRecord stored retired picklist value = %v, want nil", err)
	}
	if repo.updated == nil || repo.updated[constants.FieldIsDeleted] != false {
		t.Fatalf("RestoreRecord update = %#v, want reactivated row", repo.updated)
	}
}

func TestRestoreRecordAllowsUnchangedRetiredDependentPicklistValue(t *testing.T) {
	user := recordEntrypointUser()
	controller := "CountryCode"
	schema := restoreObjectSchema("Account")
	schema.Fields = append(schema.Fields,
		models.FieldMetadata{
			APIName: controller, Type: constants.FieldTypePicklist,
			Options: models.PicklistOptionsFromStrings([]string{"US"}),
		},
		models.FieldMetadata{
			APIName: "Province__c", Type: constants.FieldTypePicklist,
			ControllingField:   &controller,
			PicklistDependency: map[string][]string{"US": {"CA"}},
			Options:            models.PicklistOptionsFromStrings([]string{"CA"}),
		},
	)
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	repo.records["account|rec-1"][controller] = "US"
	repo.records["account|rec-1"]["Province__c"] = "RetiredProvince"
	ps := newRecycleBinRestoreService(t, repo, true, schema)

	if err := ps.RestoreRecord(context.Background(), "rec-1", user); err != nil {
		t.Fatalf("RestoreRecord unchanged retired dependent picklist = %v, want nil", err)
	}
}

func TestRestoreRecordRevalidatesDependentPicklistWhenHookChangesController(t *testing.T) {
	user := recordEntrypointUser()
	controller := "CountryCode"
	schema := restoreObjectSchema("Account")
	schema.Fields = append(schema.Fields,
		models.FieldMetadata{
			APIName: controller, Type: constants.FieldTypePicklist,
			Options: models.PicklistOptionsFromStrings([]string{"CN", "US"}),
		},
		models.FieldMetadata{
			APIName: "Province__c", Type: constants.FieldTypePicklist,
			ControllingField:   &controller,
			PicklistDependency: map[string][]string{"CN": {"BJ"}, "US": {"CA"}},
			Options:            models.PicklistOptionsFromStrings([]string{"BJ", "CA"}),
		},
	)
	schema.AccessPolicy = &models.ObjectAccessPolicy{
		WriteValidation: &models.RecordWriteValidationAccessPolicy{
			Canonicalizers: []models.RecordWriteCanonicalizationPolicy{{
				Phase: string(contract.RecordWriteValidationBeforeRestore), Capability: "test.restore.controller-change",
			}},
		},
	}
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	repo.records["account|rec-1"][controller] = "US"
	repo.records["account|rec-1"]["Province__c"] = "CA"
	ps := newRecycleBinRestoreService(t, repo, true, schema)
	if err := ps.RegisterRecordWriteCanonicalizer(contract.RecordWriteCanonicalizerRegistration{
		Capability: "test.restore.controller-change", Phase: contract.RecordWriteValidationBeforeRestore,
		Canonicalizer: func(_ context.Context, event contract.RecordWriteCanonicalizationEvent) error {
			event.Record[controller] = "CN"
			return nil
		},
	}); err != nil {
		t.Fatal(err)
	}

	err := ps.RestoreRecord(context.Background(), "rec-1", user)
	if statusOfAPIError(t, err) != http.StatusBadRequest || !strings.Contains(err.Error(), "Province__c") {
		t.Fatalf("RestoreRecord controller-changed dependent picklist = %v, want Province__c validation", err)
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated invalid dependent picklist: %#v", repo.updated)
	}
}

func TestRestoreRecordRejectsRestoreHookIntroducingRetiredPicklistValue(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema("Account")
	schema.Fields = append(schema.Fields, models.FieldMetadata{
		APIName:    "Status__c",
		Type:       constants.FieldTypePicklist,
		Options:    models.PicklistOptionsFromStrings([]string{"Open"}),
		Createable: true,
		Updateable: true,
	})
	schema.AccessPolicy = &models.ObjectAccessPolicy{
		WriteValidation: &models.RecordWriteValidationAccessPolicy{
			Canonicalizers: []models.RecordWriteCanonicalizationPolicy{{
				Phase:      string(contract.RecordWriteValidationBeforeRestore),
				Capability: "test.restore.retired-picklist",
			}},
		},
	}
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	repo.records["account|rec-1"]["Status__c"] = "Open"
	ps := newRecycleBinRestoreService(t, repo, true, schema)
	canonicalizerCalls := 0
	if err := ps.RegisterRecordWriteCanonicalizer(contract.RecordWriteCanonicalizerRegistration{
		Capability: "test.restore.retired-picklist",
		Phase:      contract.RecordWriteValidationBeforeRestore,
		Canonicalizer: func(_ context.Context, event contract.RecordWriteCanonicalizationEvent) error {
			canonicalizerCalls++
			event.Record["Status__c"] = "Retired"
			return nil
		},
	}); err != nil {
		t.Fatalf("RegisterRecordWriteCanonicalizer: %v", err)
	}

	err := ps.RestoreRecord(context.Background(), "rec-1", user)
	if statusOfAPIError(t, err) != http.StatusBadRequest {
		t.Fatalf("RestoreRecord hook-created retired picklist error = %v, want 400", err)
	}
	if !strings.Contains(err.Error(), "Status__c") {
		t.Fatalf("RestoreRecord hook-created retired picklist error = %v, want Status__c validation", err)
	}
	if canonicalizerCalls != 1 {
		t.Fatalf("before-restore canonicalizer calls = %d, want 1", canonicalizerCalls)
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated hook-invalid record: %#v", repo.updated)
	}
}

func TestRestoreRecordRevalidatesUniqueFullImageFields(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema("Account")
	schema.Fields = append(schema.Fields, models.FieldMetadata{
		APIName:    "ExternalKey__c",
		Type:       constants.FieldTypeString,
		IsUnique:   true,
		Createable: true,
		Updateable: true,
	})
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	repo.records["account|rec-1"]["ExternalKey__c"] = "dup"
	repo.records["account|rec-2"] = models.SObject{
		constants.FieldID:        "rec-2",
		constants.FieldName:      "Live duplicate",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: false,
		"ExternalKey__c":         "dup",
	}
	ps := newRecycleBinRestoreService(t, repo, true, schema)

	err := ps.RestoreRecord(context.Background(), "rec-1", user)
	if statusOfAPIError(t, err) != http.StatusConflict {
		t.Fatalf("RestoreRecord error = %v, want 409", err)
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated duplicate unique value: %#v", repo.updated)
	}
}

func TestRestoreRecordRevalidatesRecordTypeFullImage(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema("Account")
	schema.Fields = append(schema.Fields, models.FieldMetadata{
		APIName:     constants.FieldRecordTypeID,
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{constants.ObjectRecordType},
		Createable:  true,
		Updateable:  true,
	})
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	repo.records["account|rec-1"][constants.FieldRecordTypeID] = "rt-inactive"
	ps := newRecycleBinRestoreServiceWithRecordTypes(t, repo, true, []*models.RecordType{
		{ID: "rt-inactive", ObjectAPIName: "Account", Name: "Inactive", IsActive: false},
	}, schema)

	err := ps.RestoreRecord(context.Background(), "rec-1", user)
	if statusOfAPIError(t, err) != http.StatusBadRequest {
		t.Fatalf("RestoreRecord error = %v, want 400", err)
	}
	if !strings.Contains(err.Error(), constants.FieldRecordTypeID) {
		t.Fatalf("RestoreRecord error = %v, want RecordTypeId validation", err)
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated inactive record type: %#v", repo.updated)
	}
}

func TestRestoreRecordRevalidatesQueueOwnerFullImage(t *testing.T) {
	user := &models.UserSession{ID: "005-user", ProfileID: constants.ProfileSystemAdmin, IsSystemAdmin: true}
	schema := restoreObjectSchema("Account")
	schema.SharingModel = constants.SharingModelPublicReadWrite
	schema.Fields = append(schema.Fields, models.FieldMetadata{
		APIName:     constants.FieldOwnerID,
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{constants.TableUser, constants.TableGroup},
		Createable:  true,
		Updateable:  true,
	})
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	repo.records["account|rec-1"][constants.FieldOwnerID] = "queue-1"
	repo.parents["group|queue-1"] = models.SObject{
		constants.FieldID:         "queue-1",
		constants.FieldName:       "Unsupported Queue",
		constants.FieldGroup_Type: "Queue",
		constants.FieldIsDeleted:  false,
	}
	ps := newRecycleBinRestoreService(t, repo, true, schema)

	err := ps.RestoreRecord(context.Background(), "rec-1", user)
	if statusOfAPIError(t, err) != http.StatusBadRequest {
		t.Fatalf("RestoreRecord error = %v, want 400", err)
	}
	if !strings.Contains(err.Error(), "OwnerId") {
		t.Fatalf("RestoreRecord error = %v, want OwnerId validation", err)
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated unsupported queue owner: %#v", repo.updated)
	}
}

// A connector/scheduled-created record is owned by the rowless `system` actor
// (no seeded User row). Restoring it must pass owner revalidation via the
// actor-gate: restore validates the unchanged platform owner as a platform
// write (nil actor), so undelete succeeds without a `system` User row.
func TestRestoreRecordAllowsSystemOwnedFullImage(t *testing.T) {
	user := &models.UserSession{ID: "005-user", ProfileID: constants.ProfileSystemAdmin, IsSystemAdmin: true}
	schema := restoreObjectSchema("Account")
	schema.SharingModel = constants.SharingModelPublicReadWrite
	schema.Fields = append(schema.Fields, models.FieldMetadata{
		APIName:     constants.FieldOwnerID,
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{constants.TableUser},
		Createable:  true,
		Updateable:  true,
	})
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	repo.records["account|rec-1"][constants.FieldOwnerID] = constants.SystemUserID
	ps := newRecycleBinRestoreService(t, repo, true, schema)

	if err := ps.RestoreRecord(context.Background(), "rec-1", user); err != nil {
		t.Fatalf("RestoreRecord (system-owned) = %v, want nil (actor-gate accepts the unchanged platform owner)", err)
	}
	if got, ok := repo.updated[constants.FieldIsDeleted].(bool); !ok || got {
		t.Fatalf("updated IsDeleted = %#v, want false (system-owned record reactivated)", repo.updated[constants.FieldIsDeleted])
	}
}

func TestRestoreRecordAllowsKnownNonInitialStateTransitionState(t *testing.T) {
	user := recordEntrypointUser()
	stateValidator := "state_transition"
	stateConfig := `{"initial":["Draft"],"transitions":{"Draft":["Active"],"Active":[]}}`
	schema := restoreObjectSchema("Account")
	schema.Fields = append(schema.Fields, models.FieldMetadata{
		APIName:         "Status__c",
		Type:            constants.FieldTypePicklist,
		Options:         models.PicklistOptionsFromStrings([]string{"Draft", "Active"}),
		Validator:       &stateValidator,
		ValidatorConfig: &stateConfig,
		Createable:      true,
		Updateable:      true,
	})
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	repo.records["account|rec-1"]["Status__c"] = "Active"
	ps := newRecycleBinRestoreService(t, repo, true, schema)

	if err := ps.RestoreRecord(context.Background(), "rec-1", user); err != nil {
		t.Fatalf("RestoreRecord: %v", err)
	}
	if repo.updated == nil {
		t.Fatal("RestoreRecord did not update restored record")
	}
	if got := repo.updated[constants.FieldIsDeleted]; got != false {
		t.Fatalf("RestoreRecord IsDeleted update = %#v, want false", got)
	}
}

func TestRestoreRecordWriteValidatorRunsOnceWithOldRecord(t *testing.T) {
	schema := restoreObjectSchema("Account")
	schema.AccessPolicy = &models.ObjectAccessPolicy{
		WriteValidation: &models.RecordWriteValidationAccessPolicy{
			Validators: []models.RecordWriteValidationPolicy{{
				Phase:      string(contract.RecordWriteValidationBeforeUpdate),
				Capability: "test.restore.final",
			}},
		},
	}
	user := recordEntrypointUser()
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	ps := newRecycleBinRestoreService(t, repo, true, schema)
	var calls int

	if err := ps.RegisterRecordWriteValidator(contract.RecordWriteValidatorRegistration{
		Capability: "test.restore.final",
		Phase:      contract.RecordWriteValidationBeforeUpdate,
		Validator: func(_ context.Context, event contract.RecordWriteValidationEvent) error {
			calls++
			if event.OldRecord[constants.FieldIsDeleted] != true {
				t.Fatalf("OldRecord IsDeleted = %#v, want true", event.OldRecord[constants.FieldIsDeleted])
			}
			if event.Record[constants.FieldIsDeleted] != false {
				t.Fatalf("Record IsDeleted = %#v, want false", event.Record[constants.FieldIsDeleted])
			}
			return nil
		},
	}); err != nil {
		t.Fatalf("RegisterRecordWriteValidator returned error: %v", err)
	}

	if err := ps.RestoreRecord(context.Background(), "rec-1", user); err != nil {
		t.Fatalf("RestoreRecord: %v", err)
	}
	if calls != 1 {
		t.Fatalf("validator calls = %d, want 1 final-image invocation", calls)
	}
}

func TestRestoreRecordRunsBeforeRestoreWriteValidatorBeforeUpdateLifecycle(t *testing.T) {
	schema := restoreObjectSchema("RestoreProbe__c")
	schema.AccessPolicy = &models.ObjectAccessPolicy{
		WriteValidation: &models.RecordWriteValidationAccessPolicy{
			Validators: []models.RecordWriteValidationPolicy{{
				Phase:      string(contract.RecordWriteValidationBeforeRestore),
				Capability: "test.restore.validate",
			}},
		},
	}
	user := recordEntrypointUser()
	repo := newRecycleBinRestoreRepo("RestoreProbe__c", "rec-1", user.ID)
	ps := newRecycleBinRestoreService(t, repo, true, schema)
	sentinel := stderrors.New("blocked by restore validator")
	var called bool

	if err := ps.RegisterRecordWriteValidator(contract.RecordWriteValidatorRegistration{
		Capability: "test.restore.validate",
		Phase:      contract.RecordWriteValidationBeforeRestore,
		Validator: func(_ context.Context, event contract.RecordWriteValidationEvent) error {
			called = true
			if repo.updated != nil {
				t.Fatalf("before_restore validator ran after restore update: %#v", repo.updated)
			}
			if event.ObjectName != "RestoreProbe__c" {
				t.Fatalf("ObjectName = %q, want RestoreProbe__c", event.ObjectName)
			}
			if event.RecordID != "rec-1" {
				t.Fatalf("RecordID = %q, want rec-1", event.RecordID)
			}
			if event.Record[constants.FieldIsDeleted] != false {
				t.Fatalf("Record IsDeleted = %#v, want restored final image", event.Record[constants.FieldIsDeleted])
			}
			if event.OldRecord[constants.FieldIsDeleted] != true {
				t.Fatalf("OldRecord IsDeleted = %#v, want deleted pre-image", event.OldRecord[constants.FieldIsDeleted])
			}
			return sentinel
		},
	}); err != nil {
		t.Fatalf("RegisterRecordWriteValidator returned error: %v", err)
	}

	err := ps.RestoreRecord(context.Background(), "rec-1", user)
	if !stderrors.Is(err, sentinel) {
		t.Fatalf("RestoreRecord error = %v, want sentinel validator error", err)
	}
	if !called {
		t.Fatal("before_restore validator was not called")
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated row after failing before_restore validator: %#v", repo.updated)
	}
}

func TestRestoreRecordRestoresCascadeTreeFromDeleteLineage(t *testing.T) {
	user := recordEntrypointUser()
	parentSchema := restoreObjectSchema("Parent__c")
	childSchema := restoreObjectSchema("Child__c")
	cascade := constants.DeleteRuleCascade
	childSchema.Fields = append(childSchema.Fields, models.FieldMetadata{
		APIName:     "Parent__c",
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{"Parent__c"},
		DeleteRule:  &cascade,
		Createable:  true,
		Updateable:  true,
	})
	makeFieldsMap(childSchema)

	repo := newRecycleBinRestoreRepo("Parent__c", "parent-1", user.ID)
	operationID := "delete-op-1"
	repo.tombstone = nil
	repo.tombstones = map[string]models.SObject{
		"parent-1": restoreTombstone("Parent__c", "parent-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:       operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeDepth:            0,
		}),
		"child-1": restoreTombstone("Child__c", "child-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:          operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName:    "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:         "parent-1",
			constants.FieldSysRecycleBin_CascadeParentObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_CascadeParentRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeFieldAPIName:        "Parent__c",
			constants.FieldSysRecycleBin_CascadeDepth:               1,
		}),
	}
	repo.records["child__c|child-1"] = models.SObject{
		constants.FieldID:        "child-1",
		constants.FieldName:      "Deleted child",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: true,
		"Parent__c":              "parent-1",
	}
	ps := newRecycleBinRestoreService(t, repo, true, parentSchema, childSchema)
	rootAuthority, err := recycleBinMutationTombstoneFromRow(repo.tombstones["parent-1"])
	if err != nil {
		t.Fatalf("parse root tombstone: %v", err)
	}
	candidates, err := ps.loadRecycleBinCascadeRestoreTombstones(context.Background(), rootAuthority)
	if err != nil {
		t.Fatalf("load cascade tombstones: %v", err)
	}
	if len(candidates) != 2 {
		t.Fatalf("cascade tombstones = %#v, want root and child", candidates)
	}
	if candidates[0].recordID != "parent-1" || candidates[1].recordID != "child-1" {
		t.Fatalf("cascade tombstone order = %#v, want parent then child", candidates)
	}
	if !isRecycleBinManagedObject(ps.metadata.GetSchema(context.Background(), candidates[1].objectName)) {
		t.Fatalf("child candidate schema is not recycle-bin managed: %#v", candidates[1])
	}
	if !ps.currentUserCanSeeRecycleBinTombstone(context.Background(), candidates[1], user) {
		t.Fatalf("child candidate tombstone is not visible: %#v user=%#v", candidates[1], user)
	}

	restoreCtx := withParentLockForcedPrelocks(context.Background(), []recordlocks.ParentKey{{
		ObjectName: "Parent__c",
		RecordID:   "parent-1",
	}})
	if err := ps.RestoreRecord(restoreCtx, "parent-1", user); err != nil {
		t.Fatalf("RestoreRecord cascade tree: %v; lookups=%#v updates=%#v deletes=%#v tombstones=%#v", err, repo.lookupLog, repo.updateCalls, repo.deleteCalls, repo.tombstones)
	}
	if got := relationshipDeleteUpdateFor(repo.updateCalls, "Parent__c", "parent-1"); got == nil || got[constants.FieldIsDeleted] != false {
		t.Fatalf("parent restore update missing: %#v", repo.updateCalls)
	}
	if got := relationshipDeleteUpdateFor(repo.updateCalls, "Child__c", "child-1"); got == nil || got[constants.FieldIsDeleted] != false {
		t.Fatalf("child restore update missing: %#v", repo.updateCalls)
	}
	wantDeletes := constants.TableRecycleBin + "|rb-parent-1," + constants.TableRecycleBin + "|rb-child-1"
	if got := strings.Join(repo.deleteCalls, ","); got != wantDeletes {
		t.Fatalf("tombstone deletes = %s, want %s", got, wantDeletes)
	}
}

func TestRestoreRecordNonRootRestoresLineageBackedDescendantSubtree(t *testing.T) {
	user := recordEntrypointUser()
	parentSchema := restoreObjectSchema("Parent__c")
	parentSchema.DeleteMode = constants.DeleteModeHard
	childSchema := restoreObjectSchema("Child__c")
	grandchildSchema := restoreObjectSchema("Grandchild__c")
	cascade := constants.DeleteRuleCascade
	childSchema.Fields = append(childSchema.Fields, models.FieldMetadata{
		APIName:     "Parent__c",
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{"Parent__c"},
		DeleteRule:  &cascade,
		Createable:  true,
		Updateable:  true,
	})
	grandchildSchema.Fields = append(grandchildSchema.Fields, models.FieldMetadata{
		APIName:     "Child__c",
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{"Child__c"},
		DeleteRule:  &cascade,
		Createable:  true,
		Updateable:  true,
	})
	makeFieldsMap(childSchema)
	makeFieldsMap(grandchildSchema)

	repo := newRecycleBinRestoreRepo("Child__c", "child-1", user.ID)
	operationID := "delete-op-1"
	repo.tombstone = nil
	repo.tombstones = map[string]models.SObject{
		"child-1": restoreTombstone("Child__c", "child-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:          operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName:    "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:         "parent-1",
			constants.FieldSysRecycleBin_CascadeParentObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_CascadeParentRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeFieldAPIName:        "Parent__c",
			constants.FieldSysRecycleBin_CascadeDepth:               1,
		}),
		"grandchild-1": restoreTombstone("Grandchild__c", "grandchild-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:          operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName:    "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:         "parent-1",
			constants.FieldSysRecycleBin_CascadeParentObjectAPIName: "Child__c",
			constants.FieldSysRecycleBin_CascadeParentRecordID:      "child-1",
			constants.FieldSysRecycleBin_CascadeFieldAPIName:        "Child__c",
			constants.FieldSysRecycleBin_CascadeDepth:               2,
		}),
	}
	repo.records["child__c|child-1"] = models.SObject{
		constants.FieldID:        "child-1",
		constants.FieldName:      "Deleted child",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: true,
		"Parent__c":              "parent-1",
	}
	repo.records["grandchild__c|grandchild-1"] = models.SObject{
		constants.FieldID:        "grandchild-1",
		constants.FieldName:      "Deleted grandchild",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: true,
		"Child__c":               "child-1",
	}
	repo.parents["parent__c|parent-1"] = models.SObject{
		constants.FieldID:        "parent-1",
		constants.FieldName:      "Hard-delete root",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: false,
	}
	ps := newRecycleBinRestoreService(t, repo, true, parentSchema, childSchema, grandchildSchema)

	candidates, err := ps.loadRecycleBinCascadeRestoreTombstones(context.Background(), mustRestoreTombstone(t, repo.tombstones["child-1"]))
	if err != nil {
		t.Fatalf("load non-root restore subtree: %v", err)
	}
	if len(candidates) != 2 {
		t.Fatalf("non-root restore candidates = %#v, want child and grandchild", candidates)
	}
	if candidates[0].recordID != "child-1" || candidates[1].recordID != "grandchild-1" {
		t.Fatalf("non-root restore order = %#v, want child then grandchild", candidates)
	}

	if err := ps.RestoreRecord(context.Background(), "child-1", user); err != nil {
		t.Fatalf("RestoreRecord non-root subtree: %v; updates=%#v deletes=%#v", err, repo.updateCalls, repo.deleteCalls)
	}
	if got := relationshipDeleteUpdateFor(repo.updateCalls, "Child__c", "child-1"); got == nil || got[constants.FieldIsDeleted] != false {
		t.Fatalf("child restore update missing: %#v", repo.updateCalls)
	}
	if got := relationshipDeleteUpdateFor(repo.updateCalls, "Grandchild__c", "grandchild-1"); got == nil || got[constants.FieldIsDeleted] != false {
		t.Fatalf("grandchild restore update missing: %#v", repo.updateCalls)
	}
	if got := relationshipDeleteUpdateFor(repo.updateCalls, "Parent__c", "parent-1"); got != nil {
		t.Fatalf("non-root restore updated hard-delete ancestor: %#v", repo.updateCalls)
	}
	wantDeletes := constants.TableRecycleBin + "|rb-child-1," + constants.TableRecycleBin + "|rb-grandchild-1"
	if got := strings.Join(repo.deleteCalls, ","); got != wantDeletes {
		t.Fatalf("tombstone deletes = %s, want %s", got, wantDeletes)
	}
}

func TestRestoreRecordNonRootIgnoresUnselectedSiblingMetadataDrift(t *testing.T) {
	user := recordEntrypointUser()
	parentSchema := restoreObjectSchema("Parent__c")
	childSchema := restoreObjectSchema("Child__c")
	grandchildSchema := restoreObjectSchema("Grandchild__c")
	cascade := constants.DeleteRuleCascade
	childSchema.Fields = append(childSchema.Fields, models.FieldMetadata{
		APIName:     "Parent__c",
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{"Parent__c"},
		DeleteRule:  &cascade,
		Createable:  true,
		Updateable:  true,
	})
	grandchildSchema.Fields = append(grandchildSchema.Fields, models.FieldMetadata{
		APIName:     "Child__c",
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{"Child__c"},
		DeleteRule:  &cascade,
		Createable:  true,
		Updateable:  true,
	})
	makeFieldsMap(childSchema)
	makeFieldsMap(grandchildSchema)

	repo := newRecycleBinRestoreRepo("Child__c", "child-1", user.ID)
	operationID := "delete-op-1"
	repo.tombstone = nil
	repo.tombstones = map[string]models.SObject{
		"parent-1": restoreTombstone("Parent__c", "parent-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:       operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeDepth:            0,
		}),
		"child-1": restoreTombstone("Child__c", "child-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:          operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName:    "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:         "parent-1",
			constants.FieldSysRecycleBin_CascadeParentObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_CascadeParentRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeFieldAPIName:        "Parent__c",
			constants.FieldSysRecycleBin_CascadeDepth:               1,
		}),
		"grandchild-1": restoreTombstone("Grandchild__c", "grandchild-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:          operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName:    "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:         "parent-1",
			constants.FieldSysRecycleBin_CascadeParentObjectAPIName: "Child__c",
			constants.FieldSysRecycleBin_CascadeParentRecordID:      "child-1",
			constants.FieldSysRecycleBin_CascadeFieldAPIName:        "Child__c",
			constants.FieldSysRecycleBin_CascadeDepth:               2,
		}),
		"sibling-1": restoreTombstone("Sibling__c", "sibling-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:          operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName:    "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:         "parent-1",
			constants.FieldSysRecycleBin_CascadeParentObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_CascadeParentRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeFieldAPIName:        "Parent__c",
			constants.FieldSysRecycleBin_CascadeDepth:               1,
		}),
	}
	repo.records["child__c|child-1"] = models.SObject{
		constants.FieldID:        "child-1",
		constants.FieldName:      "Deleted child",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: true,
		"Parent__c":              "parent-1",
	}
	repo.records["grandchild__c|grandchild-1"] = models.SObject{
		constants.FieldID:        "grandchild-1",
		constants.FieldName:      "Deleted grandchild",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: true,
		"Child__c":               "child-1",
	}
	repo.parents["parent__c|parent-1"] = models.SObject{
		constants.FieldID:        "parent-1",
		constants.FieldName:      "Live parent",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: false,
	}
	ps := newRecycleBinRestoreService(t, repo, true, parentSchema, childSchema, grandchildSchema)

	candidates, err := ps.loadRecycleBinCascadeRestoreTombstones(context.Background(), mustRestoreTombstone(t, repo.tombstones["child-1"]))
	if err != nil {
		t.Fatalf("load non-root restore subtree with unrelated sibling drift: %v", err)
	}
	if len(candidates) != 2 {
		t.Fatalf("non-root restore candidates = %#v, want child and grandchild only", candidates)
	}

	if err := ps.RestoreRecord(context.Background(), "child-1", user); err != nil {
		t.Fatalf("RestoreRecord non-root subtree with unrelated sibling drift: %v; updates=%#v deletes=%#v", err, repo.updateCalls, repo.deleteCalls)
	}
	if got := relationshipDeleteUpdateFor(repo.updateCalls, "Child__c", "child-1"); got == nil || got[constants.FieldIsDeleted] != false {
		t.Fatalf("child restore update missing: %#v", repo.updateCalls)
	}
	if got := relationshipDeleteUpdateFor(repo.updateCalls, "Grandchild__c", "grandchild-1"); got == nil || got[constants.FieldIsDeleted] != false {
		t.Fatalf("grandchild restore update missing: %#v", repo.updateCalls)
	}
	if got := relationshipDeleteUpdateFor(repo.updateCalls, "Sibling__c", "sibling-1"); got != nil {
		t.Fatalf("non-root restore updated unselected sibling branch: %#v", repo.updateCalls)
	}
	wantDeletes := constants.TableRecycleBin + "|rb-child-1," + constants.TableRecycleBin + "|rb-grandchild-1"
	if got := strings.Join(repo.deleteCalls, ","); got != wantDeletes {
		t.Fatalf("tombstone deletes = %s, want %s", got, wantDeletes)
	}
}

func TestRestoreRecordRejectsDirectCascadeChildWhenRelationshipNoLongerCascade(t *testing.T) {
	user := recordEntrypointUser()
	parentSchema := restoreObjectSchema("Parent__c")
	childSchema := restoreObjectSchema("Child__c")
	setNull := constants.DeleteRuleSetNull
	childSchema.Fields = append(childSchema.Fields, models.FieldMetadata{
		APIName:     "Parent__c",
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{"Parent__c"},
		DeleteRule:  &setNull,
		Createable:  true,
		Updateable:  true,
	})
	makeFieldsMap(childSchema)

	repo := newRecycleBinRestoreRepo("Child__c", "child-1", user.ID)
	repo.tombstone = restoreTombstone("Child__c", "child-1", user.ID, models.SObject{
		constants.FieldSysRecycleBin_DeleteOperationID:          "delete-op-1",
		constants.FieldSysRecycleBin_DeleteRootObjectAPIName:    "Parent__c",
		constants.FieldSysRecycleBin_DeleteRootRecordID:         "parent-1",
		constants.FieldSysRecycleBin_CascadeParentObjectAPIName: "Parent__c",
		constants.FieldSysRecycleBin_CascadeParentRecordID:      "parent-1",
		constants.FieldSysRecycleBin_CascadeFieldAPIName:        "Parent__c",
		constants.FieldSysRecycleBin_CascadeDepth:               1,
	})
	repo.records["child__c|child-1"]["Parent__c"] = "parent-1"
	repo.parents["parent__c|parent-1"] = models.SObject{
		constants.FieldID:        "parent-1",
		constants.FieldName:      "Parent",
		constants.FieldIsDeleted: false,
	}
	ps := newRecycleBinRestoreService(t, repo, true, parentSchema, childSchema)

	err := ps.RestoreRecord(context.Background(), "child-1", user)
	if statusOfAPIError(t, err) != http.StatusConflict {
		t.Fatalf("RestoreRecord error = %v, want 409", err)
	}
	if len(repo.updateCalls) != 0 {
		t.Fatalf("RestoreRecord updated direct cascade child after metadata changed: %#v", repo.updateCalls)
	}
}

func TestRestoreRecordAllowsLegacyLeafWithoutDeleteOperationId(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema("Account")
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	removeRecycleBinLineage(repo.tombstone)
	ps := newRecycleBinRestoreService(t, repo, true, schema)

	if err := ps.RestoreRecord(context.Background(), "rec-1", user); err != nil {
		t.Fatalf("RestoreRecord legacy leaf: %v", err)
	}
	if got := relationshipDeleteUpdateFor(repo.updateCalls, "Account", "rec-1"); got == nil || got[constants.FieldIsDeleted] != false {
		t.Fatalf("restore update missing for legacy leaf: %#v", repo.updateCalls)
	}
}

func TestRestoreRecordPermissionSetAssignmentLocksTargetBeforeAssignment(t *testing.T) {
	user := recordEntrypointUser()
	permissionSetSchema := restoreObjectSchema(constants.TablePermissionSet)
	assignmentSchema := restoreObjectSchema(constants.TablePermissionSetAssignment)
	assignmentSchema.Fields = append(
		assignmentSchema.Fields,
		models.FieldMetadata{
			APIName:    constants.FieldPermissionSetAssignment_AssigneeID,
			Type:       constants.FieldTypeString,
			Createable: true,
			Updateable: true,
		},
		models.FieldMetadata{
			APIName:    constants.FieldPermissionSetAssignment_PermissionSetID,
			Type:       constants.FieldTypeString,
			Createable: true,
			Updateable: true,
		},
	)
	assignmentSchema.AccessPolicy = permissionSetAssignmentWriteValidationAccessPolicy()
	assignmentSchema.AccessPolicy.CacheInvalidation = &models.RecordCacheInvalidationAccessPolicy{
		Handlers: []models.RecordCacheInvalidationPolicy{
			{Capability: securityCachePermissionSetAssignmentUsersCapability},
		},
	}
	makeFieldsMap(assignmentSchema)

	repo := newRecycleBinRestoreRepo(constants.TablePermissionSetAssignment, "psa-managed", user.ID)
	repo.records[strings.ToLower(constants.TablePermissionSetAssignment)+"|psa-managed"] = models.SObject{
		constants.FieldID:                                      "psa-managed",
		constants.FieldOwnerID:                                 user.ID,
		constants.FieldIsDeleted:                               true,
		constants.FieldPermissionSetAssignment_AssigneeID:      "assignee-managed",
		constants.FieldPermissionSetAssignment_PermissionSetID: "ps-managed",
	}
	repo.records[strings.ToLower(constants.TablePermissionSet)+"|ps-managed"] = models.SObject{
		constants.FieldID: "ps-managed",
		constants.FieldPermissionSet_IsOwnedByProfile: false,
		constants.FieldPermissionSet_IsActive:         true,
		constants.FieldIsDeleted:                      false,
	}
	ps := newRecycleBinRestoreService(t, repo, true, permissionSetSchema, assignmentSchema)

	if err := ps.RestoreRecord(context.Background(), "psa-managed", user); err != nil {
		t.Fatalf("RestoreRecord PermissionSetAssignment: %v", err)
	}
	assertLockBefore(
		t,
		repo.lookupLog,
		"lock:"+constants.TablePermissionSet+":ps-managed",
		"lock:"+constants.TablePermissionSetAssignment+":psa-managed",
	)
}

func TestRestoreRecordCascadeInvalidatesDescendantSecurityCaches(t *testing.T) {
	user := recordEntrypointUser()
	parentSchema := restoreObjectSchema("Parent__c")
	childSchema := restoreObjectSchema(constants.TablePermissionSetAssignment)
	cascade := constants.DeleteRuleCascade
	childSchema.Fields = append(childSchema.Fields,
		models.FieldMetadata{APIName: "Parent__c", Type: constants.FieldTypeReference, ReferenceTo: []string{"Parent__c"}, DeleteRule: &cascade, Createable: true, Updateable: true},
		models.FieldMetadata{APIName: constants.FieldPermissionSetAssignment_AssigneeID, Type: constants.FieldTypeReference, ReferenceTo: []string{constants.TableUser}, Createable: true, Updateable: true},
	)
	childSchema.AccessPolicy = &models.ObjectAccessPolicy{
		CacheInvalidation: &models.RecordCacheInvalidationAccessPolicy{
			Handlers: []models.RecordCacheInvalidationPolicy{
				{Capability: securityCachePermissionSetAssignmentUsersCapability},
			},
		},
		WriteValidation: &models.RecordWriteValidationAccessPolicy{
			Validators: []models.RecordWriteValidationPolicy{
				{Phase: string(contract.RecordWriteValidationBeforeCreate), Capability: securityWriteValidatePermissionSetAssignmentTargetCapability},
				{Phase: string(contract.RecordWriteValidationBeforeUpdate), Capability: securityWriteValidatePermissionSetAssignmentTargetCapability},
				{Phase: string(contract.RecordWriteValidationBeforeDelete), Capability: securityWriteValidatePermissionSetAssignmentTargetCapability},
				{Phase: string(contract.RecordWriteValidationBeforeRestore), Capability: securityWriteValidatePermissionSetAssignmentTargetCapability},
				{Phase: string(contract.RecordWriteValidationBeforePurge), Capability: securityWriteValidatePermissionSetAssignmentTargetCapability},
			},
		},
	}
	makeFieldsMap(childSchema)

	repo := newRecycleBinRestoreRepo("Parent__c", "parent-1", user.ID)
	operationID := "delete-op-1"
	repo.tombstone = nil
	repo.tombstones = map[string]models.SObject{
		"parent-1": restoreTombstone("Parent__c", "parent-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:       operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeDepth:            0,
		}),
		"psa-1": restoreTombstone(constants.TablePermissionSetAssignment, "psa-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:          operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName:    "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:         "parent-1",
			constants.FieldSysRecycleBin_CascadeParentObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_CascadeParentRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeFieldAPIName:        "Parent__c",
			constants.FieldSysRecycleBin_CascadeDepth:               1,
		}),
	}
	repo.records[strings.ToLower(constants.TablePermissionSetAssignment)+"|psa-1"] = models.SObject{
		constants.FieldID:        "psa-1",
		constants.FieldName:      "Deleted assignment",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: true,
		"Parent__c":              "parent-1",
		constants.FieldPermissionSetAssignment_AssigneeID: "005-assignee",
	}
	ps := newRecycleBinRestoreService(t, repo, true, parentSchema, childSchema)
	cache := &restorePermissionCacheSpy{}
	ps.permissions.SetCacheInvalidator(cache)

	if err := ps.RestoreRecord(context.Background(), "parent-1", user); err != nil {
		t.Fatalf("RestoreRecord cascade security invalidation: %v; updates=%#v", err, repo.updateCalls)
	}
	if !containsString(cache.invalidatedUsers, "005-assignee") {
		t.Fatalf("invalidated users = %#v, want 005-assignee", cache.invalidatedUsers)
	}
}

func TestRestoreRecordLocksCascadeTombstonesBeforeRecords(t *testing.T) {
	user := recordEntrypointUser()
	parentSchema := restoreObjectSchema("Parent__c")
	childSchema := restoreObjectSchema("Child__c")
	cascade := constants.DeleteRuleCascade
	childSchema.Fields = append(childSchema.Fields, models.FieldMetadata{
		APIName:     "Parent__c",
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{"Parent__c"},
		DeleteRule:  &cascade,
		Createable:  true,
		Updateable:  true,
	})
	makeFieldsMap(childSchema)

	repo := newRecycleBinRestoreRepo("Parent__c", "parent-1", user.ID)
	operationID := "delete-op-1"
	repo.tombstone = nil
	repo.tombstones = map[string]models.SObject{
		"parent-1": restoreTombstone("Parent__c", "parent-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:       operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeDepth:            0,
		}),
		"child-1": restoreTombstone("Child__c", "child-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:          operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName:    "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:         "parent-1",
			constants.FieldSysRecycleBin_CascadeParentObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_CascadeParentRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeFieldAPIName:        "Parent__c",
			constants.FieldSysRecycleBin_CascadeDepth:               1,
		}),
	}
	repo.records["child__c|child-1"] = models.SObject{
		constants.FieldID:        "child-1",
		constants.FieldName:      "Deleted child",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: true,
		"Parent__c":              "parent-1",
	}
	ps := newRecycleBinRestoreService(t, repo, true, parentSchema, childSchema)

	if err := ps.RestoreRecord(context.Background(), "parent-1", user); err != nil {
		t.Fatalf("RestoreRecord cascade tree: %v; lookups=%#v", err, repo.lookupLog)
	}
	assertLockBefore(t, repo.lookupLog, "lock:"+constants.TableRecycleBin+":rb-parent-1", "lock:Parent__c:parent-1")
	assertLockBefore(t, repo.lookupLog, "lock:"+constants.TableRecycleBin+":rb-child-1", "lock:Child__c:child-1")
	assertLockBefore(t, repo.lookupLog, "lock:"+constants.TableRecycleBin+":rb-child-1", "lock:Parent__c:parent-1")
}

func TestRestoreRecordRejectsLegacyCascadeTreeWithoutDeleteOperationId(t *testing.T) {
	user := recordEntrypointUser()
	parentSchema := restoreObjectSchema("Parent__c")
	childSchema := restoreObjectSchema("Child__c")
	cascade := constants.DeleteRuleCascade
	childSchema.Fields = append(childSchema.Fields,
		models.FieldMetadata{APIName: "Parent__c", Type: constants.FieldTypeReference, ReferenceTo: []string{"Parent__c"}, DeleteRule: &cascade, Createable: true, Updateable: true},
		models.FieldMetadata{APIName: "IsDefault__c", Type: constants.FieldTypeBoolean, Createable: true, Updateable: true},
	)
	childSchema.ExclusiveSiblingFields = []models.ExclusiveSiblingField{{
		FieldAPIName:          "IsDefault__c",
		PartitionFieldAPIName: "Parent__c",
	}}
	makeFieldsMap(childSchema)
	repo := newRecycleBinRestoreRepo("Parent__c", "parent-1", user.ID)
	repo.tombstone = nil
	repo.tombstones = map[string]models.SObject{
		"parent-1": restoreTombstone("Parent__c", "parent-1", user.ID, nil),
		"child-1":  restoreTombstone("Child__c", "child-1", user.ID, nil),
	}
	removeRecycleBinLineage(repo.tombstones["parent-1"])
	removeRecycleBinLineage(repo.tombstones["child-1"])
	repo.records["child__c|child-1"] = models.SObject{
		constants.FieldID:        "child-1",
		constants.FieldName:      "Deleted child",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: true,
		"Parent__c":              "parent-1",
		"IsDefault__c":           true,
	}
	ps := newRecycleBinRestoreService(t, repo, true, parentSchema, childSchema)

	err := ps.RestoreRecord(context.Background(), "parent-1", user)
	if statusOfAPIError(t, err) != http.StatusConflict {
		t.Fatalf("RestoreRecord error = %v, want 409", err)
	}
	if len(repo.updateCalls) != 0 || len(repo.deleteCalls) != 0 {
		t.Fatalf("RestoreRecord mutated unmigrated legacy cascade tree: updates=%#v deletes=%#v", repo.updateCalls, repo.deleteCalls)
	}
}

func TestRestoreRecordRejectsRootOnlyLineageWithDeletedNonLineageCascadeChild(t *testing.T) {
	user := recordEntrypointUser()
	parentSchema := restoreObjectSchema("Parent__c")
	childSchema := restoreObjectSchema("Child__c")
	cascade := constants.DeleteRuleCascade
	childSchema.Fields = append(childSchema.Fields,
		models.FieldMetadata{APIName: "Parent__c", Type: constants.FieldTypeReference, ReferenceTo: []string{"Parent__c"}, DeleteRule: &cascade, Createable: true, Updateable: true},
		models.FieldMetadata{APIName: "IsDefault__c", Type: constants.FieldTypeBoolean, Createable: true, Updateable: true},
	)
	childSchema.ExclusiveSiblingFields = []models.ExclusiveSiblingField{{
		FieldAPIName:          "IsDefault__c",
		PartitionFieldAPIName: "Parent__c",
	}}
	makeFieldsMap(childSchema)
	repo := newRecycleBinRestoreRepo("Parent__c", "parent-1", user.ID)
	repo.tombstone = nil
	repo.tombstones = map[string]models.SObject{
		"parent-1": restoreTombstone("Parent__c", "parent-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:       "legacy-root-parent-1",
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeDepth:            0,
		}),
		"child-1": restoreTombstone("Child__c", "child-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:       "legacy-root-child-1",
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName: "Child__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:      "child-1",
			constants.FieldSysRecycleBin_CascadeDepth:            0,
		}),
	}
	repo.records["child__c|child-1"] = models.SObject{
		constants.FieldID:        "child-1",
		constants.FieldName:      "Deleted child",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: true,
		"Parent__c":              "parent-1",
		"IsDefault__c":           true,
	}
	ps := newRecycleBinRestoreService(t, repo, true, parentSchema, childSchema)

	err := ps.RestoreRecord(context.Background(), "parent-1", user)
	if statusOfAPIError(t, err) != http.StatusConflict {
		t.Fatalf("RestoreRecord error = %v, want 409", err)
	}
	if len(repo.updateCalls) != 0 {
		t.Fatalf("RestoreRecord updated root-only lineage with deleted child: %#v", repo.updateCalls)
	}
	if len(repo.deleteCalls) != 0 {
		t.Fatalf("RestoreRecord deleted tombstones for root-only lineage with deleted child: %#v", repo.deleteCalls)
	}
}

func TestRestoreRecordRejectsCascadeLineageWhenDeletedChildFKDrifted(t *testing.T) {
	user := recordEntrypointUser()
	parentSchema := restoreObjectSchema("Parent__c")
	childSchema := restoreObjectSchema("Child__c")
	cascade := constants.DeleteRuleCascade
	childSchema.Fields = append(childSchema.Fields, models.FieldMetadata{
		APIName:     "Parent__c",
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{"Parent__c"},
		DeleteRule:  &cascade,
		Createable:  true,
		Updateable:  true,
	})
	makeFieldsMap(childSchema)

	repo := newRecycleBinRestoreRepo("Parent__c", "parent-1", user.ID)
	operationID := "delete-op-1"
	repo.tombstone = nil
	repo.tombstones = map[string]models.SObject{
		"parent-1": restoreTombstone("Parent__c", "parent-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:       operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeDepth:            0,
		}),
		"child-1": restoreTombstone("Child__c", "child-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:          operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName:    "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:         "parent-1",
			constants.FieldSysRecycleBin_CascadeParentObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_CascadeParentRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeFieldAPIName:        "Parent__c",
			constants.FieldSysRecycleBin_CascadeDepth:               1,
		}),
	}
	repo.records["child__c|child-1"] = models.SObject{
		constants.FieldID:        "child-1",
		constants.FieldName:      "Deleted child",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: true,
		"Parent__c":              "other-parent",
	}
	ps := newRecycleBinRestoreService(t, repo, true, parentSchema, childSchema)

	err := ps.RestoreRecord(context.Background(), "parent-1", user)
	if statusOfAPIError(t, err) != http.StatusConflict {
		t.Fatalf("RestoreRecord error = %v, want 409", err)
	}
	if len(repo.updateCalls) != 0 {
		t.Fatalf("RestoreRecord updated before rejecting drifted cascade edge: %#v", repo.updateCalls)
	}
}

func TestRestoreRecordDeepCascadeTreePrelocksLineageParents(t *testing.T) {
	user := recordEntrypointUser()
	parentSchema := restoreObjectSchema("Parent__c")
	childSchema := restoreObjectSchema("Child__c")
	grandchildSchema := restoreObjectSchema("Grandchild__c")
	greatGrandchildSchema := restoreObjectSchema("GreatGrandchild__c")
	cascade := constants.DeleteRuleCascade
	childSchema.Fields = append(childSchema.Fields, models.FieldMetadata{
		APIName:     "Parent__c",
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{"Parent__c"},
		DeleteRule:  &cascade,
		Createable:  true,
		Updateable:  true,
	})
	grandchildSchema.Fields = append(grandchildSchema.Fields, models.FieldMetadata{
		APIName:     "Child__c",
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{"Child__c"},
		DeleteRule:  &cascade,
		Createable:  true,
		Updateable:  true,
	})
	greatGrandchildSchema.Fields = append(greatGrandchildSchema.Fields, models.FieldMetadata{
		APIName:     "Grandchild__c",
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{"Grandchild__c"},
		DeleteRule:  &cascade,
		Createable:  true,
		Updateable:  true,
	})
	makeFieldsMap(childSchema)
	makeFieldsMap(grandchildSchema)
	makeFieldsMap(greatGrandchildSchema)

	repo := newRecycleBinRestoreRepo("Parent__c", "parent-1", user.ID)
	operationID := "delete-op-1"
	repo.tombstone = nil
	repo.tombstones = map[string]models.SObject{
		"parent-1": restoreTombstone("Parent__c", "parent-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:       operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeDepth:            0,
		}),
		"child-1": restoreTombstone("Child__c", "child-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:          operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName:    "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:         "parent-1",
			constants.FieldSysRecycleBin_CascadeParentObjectAPIName: "Parent__c",
			constants.FieldSysRecycleBin_CascadeParentRecordID:      "parent-1",
			constants.FieldSysRecycleBin_CascadeFieldAPIName:        "Parent__c",
			constants.FieldSysRecycleBin_CascadeDepth:               1,
		}),
		"grandchild-1": restoreTombstone("Grandchild__c", "grandchild-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:          operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName:    "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:         "parent-1",
			constants.FieldSysRecycleBin_CascadeParentObjectAPIName: "Child__c",
			constants.FieldSysRecycleBin_CascadeParentRecordID:      "child-1",
			constants.FieldSysRecycleBin_CascadeFieldAPIName:        "Child__c",
			constants.FieldSysRecycleBin_CascadeDepth:               2,
		}),
		"great-grandchild-1": restoreTombstone("GreatGrandchild__c", "great-grandchild-1", user.ID, models.SObject{
			constants.FieldSysRecycleBin_DeleteOperationID:          operationID,
			constants.FieldSysRecycleBin_DeleteRootObjectAPIName:    "Parent__c",
			constants.FieldSysRecycleBin_DeleteRootRecordID:         "parent-1",
			constants.FieldSysRecycleBin_CascadeParentObjectAPIName: "Grandchild__c",
			constants.FieldSysRecycleBin_CascadeParentRecordID:      "grandchild-1",
			constants.FieldSysRecycleBin_CascadeFieldAPIName:        "Grandchild__c",
			constants.FieldSysRecycleBin_CascadeDepth:               3,
		}),
	}
	repo.records["child__c|child-1"] = models.SObject{
		constants.FieldID:        "child-1",
		constants.FieldName:      "Deleted child",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: true,
		"Parent__c":              "parent-1",
	}
	repo.records["grandchild__c|grandchild-1"] = models.SObject{
		constants.FieldID:        "grandchild-1",
		constants.FieldName:      "Deleted grandchild",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: true,
		"Child__c":               "child-1",
	}
	repo.records["greatgrandchild__c|great-grandchild-1"] = models.SObject{
		constants.FieldID:        "great-grandchild-1",
		constants.FieldName:      "Deleted great grandchild",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: true,
		"Grandchild__c":          "grandchild-1",
	}
	ps := newRecycleBinRestoreService(t, repo, true, parentSchema, childSchema, grandchildSchema, greatGrandchildSchema)

	if err := ps.RestoreRecord(context.Background(), "parent-1", user); err != nil {
		t.Fatalf("RestoreRecord deep cascade tree: %v; updates=%#v", err, repo.updateCalls)
	}
	for _, target := range []struct {
		objectName string
		recordID   string
	}{
		{"Parent__c", "parent-1"},
		{"Child__c", "child-1"},
		{"Grandchild__c", "grandchild-1"},
		{"GreatGrandchild__c", "great-grandchild-1"},
	} {
		if got := relationshipDeleteUpdateFor(repo.updateCalls, target.objectName, target.recordID); got == nil || got[constants.FieldIsDeleted] != false {
			t.Fatalf("restore update missing for %s/%s: %#v", target.objectName, target.recordID, repo.updateCalls)
		}
	}
}

func TestRestoreRecordRevalidatesStateTransitionGraphMembership(t *testing.T) {
	user := recordEntrypointUser()
	stateValidator := "state_transition"
	stateConfig := `{"initial":["Draft"],"transitions":{"Draft":["Active"],"Active":[]}}`
	schema := restoreObjectSchema("Account")
	schema.Fields = append(schema.Fields, models.FieldMetadata{
		APIName:         "Status__c",
		Type:            constants.FieldTypePicklist,
		Options:         models.PicklistOptionsFromStrings([]string{"Draft", "Active", "Retired"}),
		Validator:       &stateValidator,
		ValidatorConfig: &stateConfig,
		Createable:      true,
		Updateable:      true,
	})
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo("Account", "rec-1", user.ID)
	repo.records["account|rec-1"]["Status__c"] = "Retired"
	ps := newRecycleBinRestoreService(t, repo, true, schema)

	err := ps.RestoreRecord(context.Background(), "rec-1", user)
	if statusOfAPIError(t, err) != http.StatusBadRequest {
		t.Fatalf("RestoreRecord error = %v, want 400", err)
	}
	if !strings.Contains(err.Error(), "Status__c") {
		t.Fatalf("RestoreRecord error = %v, want Status__c state validation", err)
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated invalid lifecycle state: %#v", repo.updated)
	}
}

func TestRestoreRecordRevalidatesSharingRuleFullImageTargets(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema(constants.TableSharingRule)
	schema.Fields = append(schema.Fields,
		models.FieldMetadata{APIName: constants.FieldSharingRule_ObjectAPIName, Type: constants.FieldTypeString, Createable: true, Updateable: true},
		models.FieldMetadata{APIName: constants.FieldSharingRule_Name, Type: constants.FieldTypeString, Createable: true, Updateable: true},
		models.FieldMetadata{APIName: constants.FieldSharingRule_Criteria, Type: constants.FieldTypeString, Createable: true, Updateable: true},
		models.FieldMetadata{APIName: constants.FieldSharingRule_ShareWithGroupID, Type: constants.FieldTypeReference, ReferenceTo: []string{constants.TableGroup}, Createable: true, Updateable: true},
	)
	schema.AccessPolicy = &models.ObjectAccessPolicy{
		WriteValidation: &models.RecordWriteValidationAccessPolicy{
			Canonicalizers: []models.RecordWriteCanonicalizationPolicy{
				{
					Phase:      string(contract.RecordWriteValidationBeforeRestore),
					Capability: sharingRuleCanonicalizationCapabilityForTest,
				},
				{
					Phase:      string(contract.RecordWriteValidationBeforeUpdate),
					Capability: sharingRuleCanonicalizationCapabilityForTest,
				},
			},
			Validators: []models.RecordWriteValidationPolicy{
				{
					Phase:      string(contract.RecordWriteValidationBeforeRestore),
					Capability: sharingRuleOwnerReferenceWriteValidationCapabilityForTest,
				},
				{
					Phase:      string(contract.RecordWriteValidationBeforeRestore),
					Capability: sharingRuleShapeWriteValidationCapabilityForTest,
				},
				{
					Phase:      string(contract.RecordWriteValidationBeforeUpdate),
					Capability: sharingRuleOwnerReferenceWriteValidationCapabilityForTest,
				},
				{
					Phase:      string(contract.RecordWriteValidationBeforeUpdate),
					Capability: sharingRuleShapeWriteValidationCapabilityForTest,
				},
			},
		},
	}
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo(constants.TableSharingRule, "rule-1", user.ID)
	repo.records["sharingrule|rule-1"][constants.FieldSharingRule_ObjectAPIName] = "Account"
	repo.records["sharingrule|rule-1"][constants.FieldSharingRule_Name] = "restore_rule"
	repo.records["sharingrule|rule-1"][constants.FieldSharingRule_Criteria] = `[{"field":"Name","op":"=","value":"Acme"}]`
	repo.records["sharingrule|rule-1"][constants.FieldSharingRule_ShareWithGroupID] = "missing-group"
	ps := newRecycleBinRestoreService(t, repo, true, schema)

	err := ps.RestoreRecord(context.Background(), "rule-1", user)
	var domainErr *appErrors.DomainWireError
	if !stderrors.As(err, &domainErr) {
		t.Fatalf("RestoreRecord error = %T %v, want *DomainWireError", err, err)
	}
	if !strings.Contains(err.Error(), constants.FieldSharingRule_ShareWithGroupID) {
		t.Fatalf("RestoreRecord error = %v, want ShareWithGroupId validation", err)
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated invalid sharing rule target: %#v", repo.updated)
	}
}

func TestRestoreSharingRuleBeforeSaveFlowCanClearInvalidOwnerReference(t *testing.T) {
	user := recordEntrypointUser()
	schema := restoreObjectSchema(constants.TableSharingRule)
	schema.Fields = append(schema.Fields,
		models.FieldMetadata{APIName: constants.FieldSharingRule_ObjectAPIName, Type: constants.FieldTypeString, Createable: true, Updateable: true},
		models.FieldMetadata{APIName: constants.FieldSharingRule_Name, Type: constants.FieldTypeString, Createable: true, Updateable: true},
		models.FieldMetadata{APIName: constants.FieldSharingRule_Criteria, Type: constants.FieldTypeString, Createable: true, Updateable: true},
		models.FieldMetadata{APIName: constants.FieldSharingRule_SharedFromGroupID, Type: constants.FieldTypeReference, ReferenceTo: []string{constants.TableGroup}, Createable: true, Updateable: true},
		models.FieldMetadata{APIName: constants.FieldSharingRule_ShareWithGroupID, Type: constants.FieldTypeReference, ReferenceTo: []string{constants.TableGroup}, Createable: true, Updateable: true},
	)
	schema.AccessPolicy = &models.ObjectAccessPolicy{
		WriteValidation: &models.RecordWriteValidationAccessPolicy{
			Canonicalizers: []models.RecordWriteCanonicalizationPolicy{
				{
					Phase:      string(contract.RecordWriteValidationBeforeRestore),
					Capability: sharingRuleCanonicalizationCapabilityForTest,
				},
				{
					Phase:      string(contract.RecordWriteValidationBeforeUpdate),
					Capability: sharingRuleCanonicalizationCapabilityForTest,
				},
			},
			Validators: []models.RecordWriteValidationPolicy{
				{
					Phase:      string(contract.RecordWriteValidationBeforeRestore),
					Capability: sharingRuleOwnerReferenceWriteValidationCapabilityForTest,
				},
				{
					Phase:      string(contract.RecordWriteValidationBeforeRestore),
					Capability: sharingRuleShapeWriteValidationCapabilityForTest,
				},
				{
					Phase:      string(contract.RecordWriteValidationBeforeUpdate),
					Capability: sharingRuleOwnerReferenceWriteValidationCapabilityForTest,
				},
				{
					Phase:      string(contract.RecordWriteValidationBeforeUpdate),
					Capability: sharingRuleShapeWriteValidationCapabilityForTest,
				},
			},
		},
	}
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo(constants.TableSharingRule, "rule-1", user.ID)
	repo.records["sharingrule|rule-1"][constants.FieldSharingRule_ObjectAPIName] = "Account"
	repo.records["sharingrule|rule-1"][constants.FieldSharingRule_Name] = "restore_rule"
	repo.records["sharingrule|rule-1"][constants.FieldSharingRule_Criteria] = `[{"field":"Name","op":"=","value":"Acme"}]`
	repo.records["sharingrule|rule-1"][constants.FieldSharingRule_SharedFromGroupID] = "missing-group"
	repo.records["sharingrule|rule-1"][constants.FieldSharingRule_ShareWithGroupID] = "group-existing-target"
	repo.parents["group|group-existing-target"] = models.SObject{constants.FieldID: "group-existing-target"}
	ps := newRecycleBinRestoreService(t, repo, true, schema)
	ps.eventBus = mutatingRecordEventBus{mutate: func(eventType sharedEvents.EventType, record models.SObject) {
		if eventType == sharedEvents.RecordBeforeUpdate {
			delete(record, constants.FieldSharingRule_SharedFromGroupID)
		}
	}}

	if err := ps.RestoreRecord(context.Background(), "rule-1", user); err != nil {
		t.Fatalf("RestoreRecord: %v", err)
	}
	if repo.updated == nil {
		t.Fatal("RestoreRecord did not update restored sharing rule")
	}
	if got, exists := repo.updated[constants.FieldSharingRule_SharedFromGroupID]; !exists || got != nil {
		t.Fatalf("restore update %s = %#v (present=%v), want explicit nil clear; updates=%#v", constants.FieldSharingRule_SharedFromGroupID, got, exists, repo.updated)
	}
}

func TestRestoreRecordBlocksMissingRequiredParent(t *testing.T) {
	user := recordEntrypointUser()
	parentSchema := restoreObjectSchema("Parent__c")
	childSchema := restoreObjectSchema("Child__c")
	childSchema.Fields = append(childSchema.Fields, models.FieldMetadata{
		APIName:     "Parent__c",
		Type:        constants.FieldTypeReference,
		ReferenceTo: []string{"Parent__c"},
		Required:    true,
		Createable:  true,
		Updateable:  true,
	})
	makeFieldsMap(childSchema)
	repo := newRecycleBinRestoreRepo("Child__c", "child-1", user.ID)
	repo.records["child__c|child-1"]["Parent__c"] = "parent-1"
	repo.parents = map[string]models.SObject{
		"parent__c|parent-1": {
			constants.FieldID:        "parent-1",
			constants.FieldName:      "Parent",
			constants.FieldIsDeleted: true,
		},
	}
	ps := newRecycleBinRestoreService(t, repo, true, parentSchema, childSchema)

	err := ps.RestoreRecord(context.Background(), "child-1", user)
	if statusOfAPIError(t, err) != http.StatusBadRequest {
		t.Fatalf("RestoreRecord error = %v, want 400", err)
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated child with deleted parent: %#v", repo.updated)
	}
}

func TestRestoreRecordBlocksExclusiveSiblingConflict(t *testing.T) {
	user := recordEntrypointUser()
	parentSchema := restoreObjectSchema("Parent__c")
	schema := restoreObjectSchema("LineItem__c")
	schema.ExclusiveSiblingFields = []models.ExclusiveSiblingField{
		{FieldAPIName: "IsPrimary__c", PartitionFieldAPIName: "ParentId"},
	}
	schema.Fields = append(schema.Fields,
		models.FieldMetadata{APIName: "IsPrimary__c", Type: constants.FieldTypeBoolean, Createable: true, Updateable: true},
		models.FieldMetadata{APIName: "ParentId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Parent__c"}, Createable: true, Updateable: true},
	)
	makeFieldsMap(schema)
	repo := newRecycleBinRestoreRepo("LineItem__c", "line-1", user.ID)
	repo.records["lineitem__c|line-1"]["ParentId"] = "parent-1"
	repo.records["lineitem__c|line-1"]["IsPrimary__c"] = true
	repo.parents["parent__c|parent-1"] = models.SObject{
		constants.FieldID:        "parent-1",
		constants.FieldName:      "Parent",
		constants.FieldIsDeleted: false,
	}
	repo.records["lineitem__c|line-2"] = models.SObject{
		constants.FieldID:        "line-2",
		constants.FieldName:      "Active sibling",
		constants.FieldOwnerID:   user.ID,
		constants.FieldIsDeleted: false,
		"ParentId":               "parent-1",
		"IsPrimary__c":           true,
	}
	ps := newRecycleBinRestoreService(t, repo, true, parentSchema, schema)

	err := ps.RestoreRecord(context.Background(), "line-1", user)
	if statusOfAPIError(t, err) != http.StatusConflict {
		t.Fatalf("RestoreRecord error = %v, want 409 conflict", err)
	}
	if repo.updated != nil {
		t.Fatalf("RestoreRecord updated exclusive-sibling conflict: %#v", repo.updated)
	}
}

func newRecycleBinRestoreRepo(objectName, recordID, deletedByID string) *recycleBinRestoreRepoStub {
	return &recycleBinRestoreRepoStub{
		tombstone: restoreTombstone(objectName, recordID, deletedByID, nil),
		records: map[string]models.SObject{
			strings.ToLower(objectName) + "|" + recordID: {
				constants.FieldID:        recordID,
				constants.FieldName:      "Deleted row",
				constants.FieldOwnerID:   deletedByID,
				constants.FieldIsDeleted: true,
			},
		},
		parents: map[string]models.SObject{},
	}
}

func restoreTombstone(objectName, recordID, deletedByID string, extra models.SObject) models.SObject {
	tombstone := models.SObject{
		constants.FieldSysRecycleBin_ID:                      "rb-" + recordID,
		constants.FieldRecordID:                              recordID,
		constants.FieldObjectAPIName:                         objectName,
		constants.FieldRecordName:                            "Deleted row",
		constants.FieldSysRecycleBin_DeletedByID:             deletedByID,
		constants.FieldSysRecycleBin_DeleteOperationID:       "root-op-" + recordID,
		constants.FieldSysRecycleBin_DeleteRootObjectAPIName: objectName,
		constants.FieldSysRecycleBin_DeleteRootRecordID:      recordID,
		constants.FieldSysRecycleBin_CascadeDepth:            0,
	}
	for field, value := range extra {
		tombstone[field] = value
	}
	return tombstone
}

func removeRecycleBinLineage(tombstone models.SObject) {
	delete(tombstone, constants.FieldSysRecycleBin_DeleteOperationID)
	delete(tombstone, constants.FieldSysRecycleBin_DeleteRootObjectAPIName)
	delete(tombstone, constants.FieldSysRecycleBin_DeleteRootRecordID)
	delete(tombstone, constants.FieldSysRecycleBin_CascadeParentObjectAPIName)
	delete(tombstone, constants.FieldSysRecycleBin_CascadeParentRecordID)
	delete(tombstone, constants.FieldSysRecycleBin_CascadeFieldAPIName)
	delete(tombstone, constants.FieldSysRecycleBin_CascadeDepth)
}

func mustRestoreTombstone(t *testing.T, row models.SObject) recycleBinMutationTombstone {
	t.Helper()
	tombstone, err := recycleBinMutationTombstoneFromRow(row)
	if err != nil {
		t.Fatalf("parse restore tombstone: %v", err)
	}
	return tombstone
}

func assertLockBefore(t *testing.T, log []string, before string, after string) {
	t.Helper()
	beforeIndex := -1
	afterIndex := -1
	for i, entry := range log {
		if beforeIndex == -1 && entry == before {
			beforeIndex = i
		}
		if afterIndex == -1 && entry == after {
			afterIndex = i
		}
	}
	if beforeIndex == -1 || afterIndex == -1 {
		t.Fatalf("lock log missing before=%q or after=%q: %#v", before, after, log)
	}
	if beforeIndex >= afterIndex {
		t.Fatalf("lock order violation: %q at %d, %q at %d, log=%#v", before, beforeIndex, after, afterIndex, log)
	}
}

func newRecycleBinRestoreService(t *testing.T, repo *recycleBinRestoreRepoStub, grantEdit bool, schemas ...*models.ObjectMetadata) *PersistenceService {
	t.Helper()
	return newRecycleBinRestoreServiceWithRecordTypes(t, repo, grantEdit, nil, schemas...)
}

func newRecycleBinRestoreServiceWithRecordTypes(t *testing.T, repo *recycleBinRestoreRepoStub, grantEdit bool, recordTypes []*models.RecordType, schemas ...*models.ObjectMetadata) *PersistenceService {
	t.Helper()
	metaSvc := metadataFeature.NewMetadataService(&fanoutMetadataRepo{schemas: schemas, recordTypes: recordTypes}, nil, nil)
	permSvc := permissions.NewPermissionService(&stubUpsertPermRepo{grantEdit: grantEdit}, metaSvc, nil)
	ps := &PersistenceService{
		repo:                      repo,
		metadata:                  metaSvc,
		permissions:               permSvc,
		eventBus:                  &eventFeature.NoOpEventBus{},
		validator:                 metadataFeature.NewValidationService(formula.NewEngine()),
		rollup:                    noopRollupProcessor{},
		txManager:                 inlineTxManager{},
		parentLaneAdmission:       &fakeParentLaneAdmissionController{},
		logger:                    logging.New(context.Background()),
		cacheInvalidations:        newRecordCacheInvalidations(),
		writeCanonicalizers:       newRecordWriteCanonicalizers(),
		assignmentPolicyAuthority: &activeClaimAuthorityTestGuard{},
	}
	ps.registerBuiltInSecurityCacheInvalidations()
	ps.registerBuiltInSecurityWriteCanonicalizers()
	ps.registerBuiltInSecurityWriteValidators()
	return installNoOpPermissionStateAuthority(ps)
}

func restoreObjectSchema(apiName string) *models.ObjectMetadata {
	schema := &models.ObjectMetadata{
		APIName:   apiName,
		TableType: constants.TableTypeStandardObject,
		Fields: []models.FieldMetadata{
			{APIName: constants.FieldID, Type: constants.FieldTypeString, IsSystem: true},
			{APIName: constants.FieldName, Type: constants.FieldTypeString, IsNameField: true},
			{APIName: constants.FieldIsDeleted, Type: constants.FieldTypeBoolean, IsSystem: true},
		},
	}
	makeFieldsMap(schema)
	return schema
}

func restoreActiveFormulaField(apiName string) models.FieldMetadata {
	mode := "active"
	formula := "1"
	returnType := constants.FieldTypeDouble
	return models.FieldMetadata{
		APIName:     apiName,
		Type:        constants.FieldTypeFormula,
		Formula:     &formula,
		FormulaMode: &mode,
		ReturnType:  &returnType,
	}
}

func statusOfAPIError(t *testing.T, err error) int {
	t.Helper()
	var apiErr *apierr.Error
	if err == nil {
		t.Fatalf("error = %v, want service error", err)
	}
	if stderrors.As(err, &apiErr) {
		return apiErr.Status
	}
	var appErr appErrors.AppError
	if stderrors.As(err, &appErr) {
		return appErr.HTTPStatus()
	}
	t.Fatalf("error = %T %v, want *apierr.Error or app error", err, err)
	return 0
}
