package records

import (
	"context"
	"errors"
	"strings"
	"testing"

	eventFeature "github.com/nexuscrm/events"
	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/kernel/pkg/recordlocks"
	"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 lifecycleSpikeEventBus struct {
	publish func(context.Context, sharedEvents.EventType, interface{}) error
}

type lifecycleRetryOnceTxManager struct {
	inlineTxManager
}

func (*lifecycleRetryOnceTxManager) WithPessimisticRetry(ctx context.Context, fn func(context.Context) error, _ int) error {
	if (inlineTxManager{}).IsPessimisticTx(ctx) {
		return fn(ctx)
	}
	if err := (inlineTxManager{}).WithPessimisticTransaction(ctx, fn); err == nil {
		return nil
	}
	return (inlineTxManager{}).WithPessimisticTransaction(ctx, fn)
}

func (b lifecycleSpikeEventBus) Subscribe(_ sharedEvents.EventType, _ ports.EventHandler) func() {
	return func() {}
}

func (b lifecycleSpikeEventBus) Publish(ctx context.Context, eventType sharedEvents.EventType, payload interface{}) error {
	if b.publish != nil {
		return b.publish(ctx, eventType, payload)
	}
	return nil
}

func (b lifecycleSpikeEventBus) PublishAsyncWithContext(_ context.Context, _ sharedEvents.EventType, _ interface{}) {
}

func lifecycleSpikeSchema() *models.ObjectMetadata {
	schema := &models.ObjectMetadata{
		ID:           "obj_lifecycle_probe",
		APIName:      "LifecycleProbe__c",
		Label:        "Lifecycle Probe",
		SharingModel: constants.SharingModelPublicReadWrite,
		Fields: []models.FieldMetadata{
			{APIName: constants.FieldID, Type: constants.FieldTypeString, Createable: false, Updateable: false},
			{APIName: constants.FieldName, Type: constants.FieldTypeString, Createable: true, Updateable: true},
		},
	}
	makeFieldsMap(schema)
	return schema
}

func lifecycleCapabilitySchema(capabilities ...string) *models.ObjectMetadata {
	return lifecycleCapabilitySchemaForPhase(contract.RecordLifecycleAfterCreateInTx, capabilities...)
}

func lifecycleCapabilitySchemaForPhase(phase contract.RecordLifecyclePhase, capabilities ...string) *models.ObjectMetadata {
	schema := lifecycleSpikeSchema()
	hooks := make([]models.RecordLifecycleHookPolicy, 0, len(capabilities))
	for _, capability := range capabilities {
		hooks = append(hooks, models.RecordLifecycleHookPolicy{
			Phase:      string(phase),
			Capability: capability,
		})
	}
	schema.AccessPolicy = &models.ObjectAccessPolicy{
		Lifecycle: &models.RecordLifecycleAccessPolicy{
			Hooks: hooks,
		},
	}
	return schema
}

func lifecycleCapabilitySchemaForPhases(capability string, phases ...contract.RecordLifecyclePhase) *models.ObjectMetadata {
	schema := lifecycleSpikeSchema()
	hooks := make([]models.RecordLifecycleHookPolicy, 0, len(phases))
	for _, phase := range phases {
		hooks = append(hooks, models.RecordLifecycleHookPolicy{
			Phase:      string(phase),
			Capability: capability,
		})
	}
	schema.AccessPolicy = &models.ObjectAccessPolicy{
		Lifecycle: &models.RecordLifecycleAccessPolicy{Hooks: hooks},
	}
	return schema
}

func TestBatchLifecycleHookFlushesOnceAtCanonicalTransactionBoundary(t *testing.T) {
	const capability = "test.lifecycle.batch"
	schema := lifecycleCapabilitySchemaForPhases(
		capability,
		contract.RecordLifecycleAfterCreateInTx,
		contract.RecordLifecycleAfterUpdateInTx,
	)
	schema.AccessPolicy.Lifecycle.AssignmentPolicyEligibility = &models.AssignmentPolicyEligibilityLifecyclePolicy{
		EnabledWhen: []models.AssignmentPolicyEligibilityBoolCondition{{Field: "Enabled__c", Equals: true}},
		Selector:    models.AssignmentPolicyEligibilitySelector{Field: "Name"},
	}
	ps := newRecordEntrypointTestService(schema, &captureRecordRepo{})

	var calls int
	var got []contract.RecordLifecycleBatchEvent
	if err := ps.RegisterRecordLifecycleBatchHook(contract.RecordLifecycleBatchRegistration{
		Capability:                     capability,
		Phases:                         []contract.RecordLifecyclePhase{contract.RecordLifecycleAfterCreateInTx, contract.RecordLifecycleAfterUpdateInTx},
		RequiresPessimisticTransaction: true,
		Hook: func(ctx context.Context, events []contract.RecordLifecycleBatchEvent) error {
			calls++
			if !ps.txManager.IsTransactionActive(ctx) || !ps.txManager.IsPessimisticTx(ctx) {
				t.Fatal("batch lifecycle hook did not run in the owned pessimistic transaction")
			}
			if recordlocks.ScopeFromContext(ctx) == nil {
				t.Fatal("batch lifecycle hook did not run inside the canonical parent-lock coordinator")
			}
			got = append([]contract.RecordLifecycleBatchEvent(nil), events...)
			return nil
		},
	}); err != nil {
		t.Fatalf("RegisterRecordLifecycleBatchHook returned error: %v", err)
	}

	err := ps.RunInPessimisticTransaction(context.Background(), func(txCtx context.Context) error {
		for _, event := range []struct {
			phase contract.RecordLifecyclePhase
			id    string
		}{
			{phase: contract.RecordLifecycleAfterCreateInTx, id: "probe-1"},
			{phase: contract.RecordLifecycleAfterUpdateInTx, id: "probe-2"},
		} {
			if err := ps.RunRecordLifecycleHooks(txCtx, event.phase, contract.RecordLifecycleEvent{
				ObjectName: "LifecycleProbe__c",
				RecordID:   event.id,
			}); err != nil {
				return err
			}
		}
		if calls != 0 {
			t.Fatalf("batch lifecycle hook ran before the transaction callback completed: calls=%d", calls)
		}
		return nil
	})
	if err != nil {
		t.Fatalf("RunInPessimisticTransaction returned error: %v", err)
	}
	if calls != 1 {
		t.Fatalf("batch lifecycle hook calls = %d, want 1", calls)
	}
	if len(got) != 2 || got[0].Phase != contract.RecordLifecycleAfterCreateInTx || got[0].Event.RecordID != "probe-1" ||
		got[1].Phase != contract.RecordLifecycleAfterUpdateInTx || got[1].Event.RecordID != "probe-2" {
		t.Fatalf("batch lifecycle events = %#v, want ordered create/update events", got)
	}
	for index, event := range got {
		if event.Policy == nil || event.Policy.AssignmentPolicyEligibility == nil {
			t.Fatalf("batch lifecycle event[%d] lost its immutable Catalog policy: %#v", index, event)
		}
	}
}

func TestBatchLifecycleHookIsDiscardedWhenTransactionCallbackFails(t *testing.T) {
	const capability = "test.lifecycle.batch"
	ps := newRecordEntrypointTestService(lifecycleCapabilitySchema(capability), &captureRecordRepo{})
	var calls int
	if err := ps.RegisterRecordLifecycleBatchHook(contract.RecordLifecycleBatchRegistration{
		Capability:                     capability,
		Phases:                         []contract.RecordLifecyclePhase{contract.RecordLifecycleAfterCreateInTx},
		RequiresPessimisticTransaction: true,
		Hook: func(context.Context, []contract.RecordLifecycleBatchEvent) error {
			calls++
			return nil
		},
	}); err != nil {
		t.Fatalf("RegisterRecordLifecycleBatchHook returned error: %v", err)
	}

	sentinel := errors.New("stop transaction")
	err := ps.RunInPessimisticTransaction(context.Background(), func(txCtx context.Context) error {
		if err := ps.RunRecordLifecycleHooks(txCtx, contract.RecordLifecycleAfterCreateInTx, contract.RecordLifecycleEvent{
			ObjectName: "LifecycleProbe__c",
			RecordID:   "probe-1",
		}); err != nil {
			return err
		}
		return sentinel
	})
	if !errors.Is(err, sentinel) {
		t.Fatalf("RunInPessimisticTransaction error = %v, want callback sentinel", err)
	}
	if calls != 0 {
		t.Fatalf("batch lifecycle hook calls = %d, want 0 for failed callback", calls)
	}
}

func TestBatchLifecycleHookRetryUsesFreshAttemptEvents(t *testing.T) {
	const capability = "test.lifecycle.batch"
	ps := newRecordEntrypointTestService(lifecycleCapabilitySchema(capability), &captureRecordRepo{})
	ps.txManager = &lifecycleRetryOnceTxManager{}
	var sizes []int
	sentinel := errors.New("retry batch lifecycle")
	if err := ps.RegisterRecordLifecycleBatchHook(contract.RecordLifecycleBatchRegistration{
		Capability:                     capability,
		Phases:                         []contract.RecordLifecyclePhase{contract.RecordLifecycleAfterCreateInTx},
		RequiresPessimisticTransaction: true,
		Hook: func(_ context.Context, events []contract.RecordLifecycleBatchEvent) error {
			sizes = append(sizes, len(events))
			if len(sizes) == 1 {
				return sentinel
			}
			return nil
		},
	}); err != nil {
		t.Fatalf("RegisterRecordLifecycleBatchHook returned error: %v", err)
	}

	err := ps.RunInPessimisticTransaction(context.Background(), func(txCtx context.Context) error {
		return ps.RunRecordLifecycleHooks(txCtx, contract.RecordLifecycleAfterCreateInTx, contract.RecordLifecycleEvent{
			ObjectName: "LifecycleProbe__c",
			RecordID:   "probe-1",
		})
	})
	if err != nil {
		t.Fatalf("RunInPessimisticTransaction returned error after retry: %v", err)
	}
	if len(sizes) != 2 || sizes[0] != 1 || sizes[1] != 1 {
		t.Fatalf("batch lifecycle event sizes across retry = %v, want [1 1]", sizes)
	}
}

func TestBatchLifecycleHookFailureFailsTheTransactionAttempt(t *testing.T) {
	const capability = "test.lifecycle.batch"
	ps := newRecordEntrypointTestService(lifecycleCapabilitySchema(capability), &captureRecordRepo{})
	sentinel := errors.New("batch lifecycle failed")
	if err := ps.RegisterRecordLifecycleBatchHook(contract.RecordLifecycleBatchRegistration{
		Capability:                     capability,
		Phases:                         []contract.RecordLifecyclePhase{contract.RecordLifecycleAfterCreateInTx},
		RequiresPessimisticTransaction: true,
		Hook: func(context.Context, []contract.RecordLifecycleBatchEvent) error {
			return sentinel
		},
	}); err != nil {
		t.Fatalf("RegisterRecordLifecycleBatchHook returned error: %v", err)
	}

	err := ps.RunInPessimisticTransaction(context.Background(), func(txCtx context.Context) error {
		return ps.RunRecordLifecycleHooks(txCtx, contract.RecordLifecycleAfterCreateInTx, contract.RecordLifecycleEvent{
			ObjectName: "LifecycleProbe__c",
			RecordID:   "probe-1",
		})
	})
	if !errors.Is(err, sentinel) {
		t.Fatalf("RunInPessimisticTransaction error = %v, want batch hook sentinel", err)
	}
}

func TestBatchLifecycleHookFailsClosedWithoutRecordsOwnedTransactionBoundary(t *testing.T) {
	const capability = "test.lifecycle.batch"
	ps := newRecordEntrypointTestService(lifecycleCapabilitySchema(capability), &captureRecordRepo{})
	if err := ps.RegisterRecordLifecycleBatchHook(contract.RecordLifecycleBatchRegistration{
		Capability:                     capability,
		Phases:                         []contract.RecordLifecyclePhase{contract.RecordLifecycleAfterCreateInTx},
		RequiresPessimisticTransaction: true,
		Hook: func(context.Context, []contract.RecordLifecycleBatchEvent) error {
			t.Fatal("batch lifecycle hook ran without the records-owned collector")
			return nil
		},
	}); err != nil {
		t.Fatalf("RegisterRecordLifecycleBatchHook returned error: %v", err)
	}

	err := inlineTxManager{}.WithPessimisticTransaction(context.Background(), func(txCtx context.Context) error {
		return ps.RunRecordLifecycleHooks(txCtx, contract.RecordLifecycleAfterCreateInTx, contract.RecordLifecycleEvent{
			ObjectName: "LifecycleProbe__c",
			RecordID:   "probe-1",
		})
	})
	if err == nil || !strings.Contains(err.Error(), "requires a records-owned transaction boundary") {
		t.Fatalf("foreign transaction lifecycle error = %v, want records-owned boundary failure", err)
	}
}

func TestBatchLifecycleHookBulkInsertReceivesOnlyLandedRowsOnce(t *testing.T) {
	const capability = "test.lifecycle.batch"
	repo := &captureRecordRepo{bulkLanding: []bool{true, false, true}}
	ps := newRecordEntrypointTestService(lifecycleCapabilitySchema(capability), repo)

	var calls int
	var got []contract.RecordLifecycleBatchEvent
	if err := ps.RegisterRecordLifecycleBatchHook(contract.RecordLifecycleBatchRegistration{
		Capability:                     capability,
		Phases:                         []contract.RecordLifecyclePhase{contract.RecordLifecycleAfterCreateInTx},
		RequiresPessimisticTransaction: true,
		Hook: func(_ context.Context, events []contract.RecordLifecycleBatchEvent) error {
			calls++
			got = append([]contract.RecordLifecycleBatchEvent(nil), events...)
			return nil
		},
	}); err != nil {
		t.Fatalf("RegisterRecordLifecycleBatchHook returned error: %v", err)
	}

	result, err := ps.BulkInsert(context.Background(), "LifecycleProbe__c", []models.SObject{
		{constants.FieldName: "landed-1"},
		{constants.FieldName: "ignored"},
		{constants.FieldName: "landed-2"},
	}, recordEntrypointUser(), BulkInsertOptions{IgnoreDuplicates: true})
	if err != nil {
		t.Fatalf("BulkInsert returned error: %v", err)
	}
	if result.SuccessCount != 3 || result.FailedCount != 0 {
		t.Fatalf("BulkInsert result = %+v, want legacy INSERT IGNORE success accounting", result)
	}
	if calls != 1 {
		t.Fatalf("batch lifecycle hook calls = %d, want 1", calls)
	}
	if len(got) != 2 {
		t.Fatalf("batch lifecycle event count = %d, want 2 landed rows: %#v", len(got), got)
	}
	if got[0].Event.RecordID != repo.bulkInserted[0][constants.FieldID] || got[0].Event.Record[constants.FieldName] != "landed-1" {
		t.Fatalf("first batch lifecycle event = %#v, want first landed row", got[0])
	}
	if got[1].Event.RecordID != repo.bulkInserted[2][constants.FieldID] || got[1].Event.Record[constants.FieldName] != "landed-2" {
		t.Fatalf("second batch lifecycle event = %#v, want second landed row", got[1])
	}
}

func TestBatchLifecycleHookCreateRecordsAllOrNoneCollectsNestedWritesOnce(t *testing.T) {
	const capability = "test.lifecycle.batch"
	ps := newRecordEntrypointTestService(lifecycleCapabilitySchema(capability), &captureRecordRepo{})
	var calls int
	var got []contract.RecordLifecycleBatchEvent
	if err := ps.RegisterRecordLifecycleBatchHook(contract.RecordLifecycleBatchRegistration{
		Capability:                     capability,
		Phases:                         []contract.RecordLifecyclePhase{contract.RecordLifecycleAfterCreateInTx},
		RequiresPessimisticTransaction: true,
		Hook: func(_ context.Context, events []contract.RecordLifecycleBatchEvent) error {
			calls++
			got = append([]contract.RecordLifecycleBatchEvent(nil), events...)
			return nil
		},
	}); err != nil {
		t.Fatalf("RegisterRecordLifecycleBatchHook returned error: %v", err)
	}

	results, err := ps.CreateRecords(context.Background(), []models.SObject{
		{"attributes": map[string]interface{}{"type": "LifecycleProbe__c"}, constants.FieldName: "first"},
		{"attributes": map[string]interface{}{"type": "LifecycleProbe__c"}, constants.FieldName: "second"},
	}, true, recordEntrypointUser())
	if err != nil {
		t.Fatalf("CreateRecords returned error: %v", err)
	}
	if len(results) != 2 || !results[0].Success || !results[1].Success {
		t.Fatalf("CreateRecords results = %#v, want two successes", results)
	}
	if calls != 1 || len(got) != 2 {
		t.Fatalf("batch lifecycle calls/events = %d/%d, want 1/2", calls, len(got))
	}
	if got[0].Event.Record[constants.FieldName] != "first" || got[1].Event.Record[constants.FieldName] != "second" {
		t.Fatalf("batch lifecycle nested-write order = %#v, want first then second", got)
	}
}

func TestBatchLifecycleHooksFlushInCanonicalCapabilityOrder(t *testing.T) {
	ps := newRecordEntrypointTestService(lifecycleCapabilitySchema("test.lifecycle.z", "test.lifecycle.a"), &captureRecordRepo{})
	var order []string
	for _, capability := range []string{"test.lifecycle.z", "test.lifecycle.a"} {
		capability := capability
		if err := ps.RegisterRecordLifecycleBatchHook(contract.RecordLifecycleBatchRegistration{
			Capability:                     capability,
			Phases:                         []contract.RecordLifecyclePhase{contract.RecordLifecycleAfterCreateInTx},
			RequiresPessimisticTransaction: true,
			Hook: func(context.Context, []contract.RecordLifecycleBatchEvent) error {
				order = append(order, capability)
				return nil
			},
		}); err != nil {
			t.Fatalf("RegisterRecordLifecycleBatchHook(%s) returned error: %v", capability, err)
		}
	}

	err := ps.RunInPessimisticTransaction(context.Background(), func(txCtx context.Context) error {
		return ps.RunRecordLifecycleHooks(txCtx, contract.RecordLifecycleAfterCreateInTx, contract.RecordLifecycleEvent{
			ObjectName: "LifecycleProbe__c",
			RecordID:   "probe-1",
		})
	})
	if err != nil {
		t.Fatalf("RunInPessimisticTransaction returned error: %v", err)
	}
	if strings.Join(order, ",") != "test.lifecycle.a,test.lifecycle.z" {
		t.Fatalf("batch lifecycle hook order = %v, want canonical capability order", order)
	}
}

func TestBatchLifecycleHookRejectsReentrantBatchStagingDuringFlush(t *testing.T) {
	const capability = "test.lifecycle.batch"
	ps := newRecordEntrypointTestService(lifecycleCapabilitySchema(capability), &captureRecordRepo{})
	if err := ps.RegisterRecordLifecycleBatchHook(contract.RecordLifecycleBatchRegistration{
		Capability:                     capability,
		Phases:                         []contract.RecordLifecyclePhase{contract.RecordLifecycleAfterCreateInTx},
		RequiresPessimisticTransaction: true,
		Hook: func(ctx context.Context, _ []contract.RecordLifecycleBatchEvent) error {
			return ps.RunRecordLifecycleHooks(ctx, contract.RecordLifecycleAfterCreateInTx, contract.RecordLifecycleEvent{
				ObjectName: "LifecycleProbe__c",
				RecordID:   "nested-probe",
			})
		},
	}); err != nil {
		t.Fatalf("RegisterRecordLifecycleBatchHook returned error: %v", err)
	}

	err := ps.RunInPessimisticTransaction(context.Background(), func(txCtx context.Context) error {
		return ps.RunRecordLifecycleHooks(txCtx, contract.RecordLifecycleAfterCreateInTx, contract.RecordLifecycleEvent{
			ObjectName: "LifecycleProbe__c",
			RecordID:   "probe-1",
		})
	})
	if err == nil || !strings.Contains(err.Error(), "cannot stage during batch flush") {
		t.Fatalf("reentrant batch lifecycle error = %v, want fail-closed staging error", err)
	}
}

func TestBatchLifecycleCapabilityCannotMixBatchAndSingleRegistrationModes(t *testing.T) {
	const capability = "test.lifecycle.batch"
	ps := newRecordEntrypointTestService(lifecycleSpikeSchema(), &captureRecordRepo{})
	if err := ps.RegisterRecordLifecycleBatchHook(contract.RecordLifecycleBatchRegistration{
		Capability: capability,
		Phases:     []contract.RecordLifecyclePhase{contract.RecordLifecycleAfterCreateInTx},
		Hook:       func(context.Context, []contract.RecordLifecycleBatchEvent) error { return nil },
	}); err != nil {
		t.Fatalf("RegisterRecordLifecycleBatchHook returned error: %v", err)
	}
	if err := ps.RegisterRecordLifecycleHook(contract.RecordLifecycleRegistration{
		Capability: capability,
		Phase:      contract.RecordLifecycleAfterUpdateInTx,
		Hook:       func(context.Context, contract.RecordLifecycleEvent) error { return nil },
	}); err == nil {
		t.Fatal("single lifecycle registration succeeded for an existing batch capability")
	}
	if err := ps.RegisterRecordLifecycleBatchHook(contract.RecordLifecycleBatchRegistration{
		Capability: capability,
		Phases:     []contract.RecordLifecyclePhase{contract.RecordLifecycleAfterDeleteInTx},
		Hook:       func(context.Context, []contract.RecordLifecycleBatchEvent) error { return nil },
	}); err == nil {
		t.Fatal("second batch lifecycle registration succeeded for an existing batch capability")
	}
}

func TestLifecycleHookPessimisticRequirementControlsDirectWrites(t *testing.T) {
	tests := []struct {
		name  string
		phase contract.RecordLifecyclePhase
		write func(*PersistenceService) error
		repo  *captureRecordRepo
	}{
		{
			name:  "insert",
			phase: contract.RecordLifecycleAfterCreateInTx,
			repo:  &captureRecordRepo{},
			write: func(ps *PersistenceService) error {
				_, err := ps.Insert(context.Background(), "LifecycleProbe__c", models.SObject{constants.FieldName: "created"}, recordEntrypointUser())
				return err
			},
		},
		{
			name:  "update",
			phase: contract.RecordLifecycleAfterUpdateInTx,
			repo: &captureRecordRepo{old: models.SObject{
				constants.FieldID: "probe-1", constants.FieldName: "before",
			}},
			write: func(ps *PersistenceService) error {
				return ps.Update(context.Background(), "LifecycleProbe__c", "probe-1", models.SObject{}, recordEntrypointUser())
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			const capability = "test.lifecycle.requires-pessimistic"
			ps := newRecordEntrypointTestService(lifecycleCapabilitySchemaForPhase(tt.phase, capability), tt.repo)
			var hookRan, hookSawPessimistic bool
			if err := ps.RegisterRecordLifecycleHook(contract.RecordLifecycleRegistration{
				Capability:                     capability,
				Phase:                          tt.phase,
				RequiresPessimisticTransaction: true,
				Hook: func(ctx context.Context, _ contract.RecordLifecycleEvent) error {
					hookRan = true
					hookSawPessimistic = ps.txManager.IsPessimisticTx(ctx)
					return nil
				},
			}); err != nil {
				t.Fatalf("RegisterRecordLifecycleHook returned error: %v", err)
			}
			if err := tt.write(ps); err != nil {
				t.Fatalf("direct write failed: %v", err)
			}
			if !hookRan || !hookSawPessimistic {
				t.Fatalf("hook ran=%v saw pessimistic=%v, want true/true", hookRan, hookSawPessimistic)
			}
		})
	}
}

func TestLifecycleHookPessimisticRequirementIsOptInAndSuppressible(t *testing.T) {
	const capability = "test.lifecycle.boundary"
	ps := newRecordEntrypointTestService(lifecycleCapabilitySchema(capability), &captureRecordRepo{})
	if err := ps.RegisterRecordLifecycleHook(contract.RecordLifecycleRegistration{
		Capability: capability,
		Phase:      contract.RecordLifecycleAfterCreateInTx,
		Hook:       func(context.Context, contract.RecordLifecycleEvent) error { return nil },
	}); err != nil {
		t.Fatalf("RegisterRecordLifecycleHook returned error: %v", err)
	}
	if requires, reason := ps.rollupInsertPessimisticBoundaryDecision(context.Background(), "LifecycleProbe__c", nil); requires || reason != "none" {
		t.Fatalf("default registration boundary = %v/%s, want false/none", requires, reason)
	}

	requiring := newRecordEntrypointTestService(lifecycleCapabilitySchema(capability), &captureRecordRepo{})
	if err := requiring.RegisterRecordLifecycleHook(contract.RecordLifecycleRegistration{
		Capability:                     capability,
		Phase:                          contract.RecordLifecycleAfterCreateInTx,
		RequiresPessimisticTransaction: true,
		Hook:                           func(context.Context, contract.RecordLifecycleEvent) error { return nil },
	}); err != nil {
		t.Fatalf("RegisterRecordLifecycleHook returned error: %v", err)
	}
	suppressed := contract.WithRecordLifecycleHooksSuppressed(context.Background())
	if requires, reason := requiring.rollupInsertPessimisticBoundaryDecision(suppressed, "LifecycleProbe__c", nil); requires || reason != "none" {
		t.Fatalf("suppressed registration boundary = %v/%s, want false/none", requires, reason)
	}
}

func TestLifecycleHookPessimisticRequirementFailsClosedWithoutBoundary(t *testing.T) {
	const capability = "test.lifecycle.requires-pessimistic"
	ps := newRecordEntrypointTestService(lifecycleCapabilitySchema(capability), &captureRecordRepo{})
	var hookRan bool
	if err := ps.RegisterRecordLifecycleHook(contract.RecordLifecycleRegistration{
		Capability:                     capability,
		Phase:                          contract.RecordLifecycleAfterCreateInTx,
		RequiresPessimisticTransaction: true,
		Hook: func(context.Context, contract.RecordLifecycleEvent) error {
			hookRan = true
			return nil
		},
	}); err != nil {
		t.Fatalf("RegisterRecordLifecycleHook returned error: %v", err)
	}
	err := ps.RunRecordLifecycleHooks(context.Background(), contract.RecordLifecycleAfterCreateInTx, contract.RecordLifecycleEvent{
		ObjectName: "LifecycleProbe__c", RecordID: "probe-1",
	})
	if err == nil || !strings.Contains(err.Error(), "requires an active pessimistic transaction") {
		t.Fatalf("RunRecordLifecycleHooks error = %v, want pessimistic transaction requirement", err)
	}
	if hookRan {
		t.Fatal("pessimistic lifecycle hook ran without the required transaction boundary")
	}
}

func TestLifecycleSpikeBeforeCreateSeesGeneratedIDBeforeSQLInsert(t *testing.T) {
	repo := &captureRecordRepo{}
	ps := newRecordEntrypointTestService(lifecycleSpikeSchema(), repo)

	var publishCount int
	var txActive bool
	var insertAlreadyRan bool
	var beforeRecord models.SObject

	ps.eventBus = lifecycleSpikeEventBus{publish: func(ctx context.Context, eventType sharedEvents.EventType, payload interface{}) error {
		if eventType != sharedEvents.RecordBeforeCreate {
			return nil
		}
		publishCount++
		recordPayload, ok := payload.(eventFeature.RecordEventPayload)
		if !ok {
			t.Fatalf("RecordBeforeCreate payload type = %T, want RecordEventPayload", payload)
		}
		if recordPayload.ObjectAPIName != "LifecycleProbe__c" {
			t.Fatalf("ObjectAPIName = %q, want LifecycleProbe__c", recordPayload.ObjectAPIName)
		}
		txActive = inlineTxManager{}.IsTransactionActive(ctx)
		insertAlreadyRan = repo.inserted != nil
		beforeRecord = cloneSObject(recordPayload.Record)
		return nil
	}}

	got, err := ps.Insert(context.Background(), "LifecycleProbe__c", models.SObject{
		constants.FieldName: "spike",
	}, recordEntrypointUser())
	if err != nil {
		t.Fatalf("Insert failed: %v", err)
	}
	if publishCount != 1 {
		t.Fatalf("RecordBeforeCreate publish count = %d, want 1", publishCount)
	}
	if !txActive {
		t.Fatal("RecordBeforeCreate did not run inside the write transaction")
	}
	if insertAlreadyRan {
		t.Fatal("RecordBeforeCreate ran after repo.Insert; expected pre-SQL hook point")
	}
	beforeID, _ := beforeRecord[constants.FieldID].(string)
	if beforeID == "" {
		t.Fatalf("RecordBeforeCreate record has no generated Id: %#v", beforeRecord)
	}
	if repo.inserted == nil {
		t.Fatal("repo.Insert was not called after successful RecordBeforeCreate")
	}
	if got[constants.FieldID] != beforeID {
		t.Fatalf("Insert result Id = %v, want RecordBeforeCreate Id %q", got[constants.FieldID], beforeID)
	}
	if repo.inserted[constants.FieldID] != beforeID {
		t.Fatalf("repo.Insert Id = %v, want RecordBeforeCreate Id %q", repo.inserted[constants.FieldID], beforeID)
	}
}

func TestLifecycleSpikeBeforeCreateFailureAbortsSQLInsert(t *testing.T) {
	repo := &captureRecordRepo{}
	ps := newRecordEntrypointTestService(lifecycleSpikeSchema(), repo)

	sentinel := errors.New("stop lifecycle spike")
	ps.eventBus = lifecycleSpikeEventBus{publish: func(_ context.Context, eventType sharedEvents.EventType, _ interface{}) error {
		if eventType == sharedEvents.RecordBeforeCreate {
			return sentinel
		}
		return nil
	}}

	_, err := ps.Insert(context.Background(), "LifecycleProbe__c", models.SObject{
		constants.FieldName: "spike",
	}, recordEntrypointUser())
	if err == nil {
		t.Fatal("Insert succeeded, want RecordBeforeCreate failure")
	}
	if !errors.Is(err, sentinel) {
		t.Fatalf("Insert error = %v, want wrapped sentinel", err)
	}
	if repo.inserted != nil {
		t.Fatalf("repo.Insert ran after failing RecordBeforeCreate: %#v", repo.inserted)
	}
}

func TestLifecycleSpikeBulkInsertPublishesRecordBeforeCreateBeforeSQLInsert(t *testing.T) {
	repo := &captureRecordRepo{}
	ps := newRecordEntrypointTestService(lifecycleSpikeSchema(), repo)

	var beforeCreateCount int
	var txActive bool
	var insertAlreadyRan bool
	var beforeRecord models.SObject
	ps.eventBus = lifecycleSpikeEventBus{publish: func(ctx context.Context, eventType sharedEvents.EventType, payload interface{}) error {
		if eventType != sharedEvents.RecordBeforeCreate {
			return nil
		}
		beforeCreateCount++
		recordPayload, ok := payload.(eventFeature.RecordEventPayload)
		if !ok {
			t.Fatalf("RecordBeforeCreate payload type = %T, want RecordEventPayload", payload)
		}
		txActive = inlineTxManager{}.IsTransactionActive(ctx)
		insertAlreadyRan = len(repo.bulkInserted) > 0
		beforeRecord = cloneSObject(recordPayload.Record)
		recordPayload.Record[constants.FieldName] = "bulk-before-save"
		return nil
	}}

	result, err := ps.BulkInsert(context.Background(), "LifecycleProbe__c", []models.SObject{{
		constants.FieldName: "bulk-spike",
	}}, recordEntrypointUser(), BulkInsertOptions{})
	if err != nil {
		t.Fatalf("BulkInsert failed: %v", err)
	}
	if result.SuccessCount != 1 || result.FailedCount != 0 {
		t.Fatalf("BulkInsert result = %+v, want one success", result)
	}
	if len(repo.bulkInserted) != 1 {
		t.Fatalf("repo.BulkInsert rows = %d, want 1", len(repo.bulkInserted))
	}
	if beforeCreateCount != 1 {
		t.Fatalf("BulkInsert RecordBeforeCreate publish count = %d, want 1", beforeCreateCount)
	}
	if !txActive {
		t.Fatal("BulkInsert RecordBeforeCreate did not run inside the write transaction")
	}
	if insertAlreadyRan {
		t.Fatal("BulkInsert RecordBeforeCreate ran after repo.BulkInsert; expected pre-SQL hook point")
	}
	beforeID, _ := beforeRecord[constants.FieldID].(string)
	if beforeID == "" {
		t.Fatalf("BulkInsert RecordBeforeCreate record has no generated Id: %#v", beforeRecord)
	}
	if got := repo.bulkInserted[0][constants.FieldName]; got != "bulk-before-save" {
		t.Fatalf("repo.BulkInsert Name = %v, want before-save mutation", got)
	}
}

func TestLifecycleSpikeBulkInsertBeforeCreateFailureDropsOnlyFailedRow(t *testing.T) {
	repo := &captureRecordRepo{}
	ps := newRecordEntrypointTestService(lifecycleSpikeSchema(), repo)

	sentinel := errors.New("stop failed row")
	ps.eventBus = lifecycleSpikeEventBus{publish: func(_ context.Context, eventType sharedEvents.EventType, payload interface{}) error {
		if eventType != sharedEvents.RecordBeforeCreate {
			return nil
		}
		recordPayload, ok := payload.(eventFeature.RecordEventPayload)
		if !ok {
			t.Fatalf("RecordBeforeCreate payload type = %T, want RecordEventPayload", payload)
		}
		if recordPayload.Record[constants.FieldName] == "bad-row" {
			return sentinel
		}
		recordPayload.Record[constants.FieldName] = "good-row-materialized"
		return nil
	}}

	result, err := ps.BulkInsert(context.Background(), "LifecycleProbe__c", []models.SObject{
		{constants.FieldName: "good-row"},
		{constants.FieldName: "bad-row"},
	}, recordEntrypointUser(), BulkInsertOptions{})
	if err != nil {
		t.Fatalf("BulkInsert failed: %v", err)
	}
	if result.SuccessCount != 1 || result.FailedCount != 1 {
		t.Fatalf("BulkInsert result = %+v, want one success and one failed before-save row", result)
	}
	if len(repo.bulkInserted) != 1 {
		t.Fatalf("repo.BulkInsert rows = %d, want only the successful row", len(repo.bulkInserted))
	}
	if got := repo.bulkInserted[0][constants.FieldName]; got != "good-row-materialized" {
		t.Fatalf("repo.BulkInsert Name = %v, want successful row before-save mutation", got)
	}
}

func TestNativeLifecycleAfterCreateRunsAfterSQLInsertInTransaction(t *testing.T) {
	repo := &captureRecordRepo{}
	ps := newRecordEntrypointTestService(lifecycleCapabilitySchema("test.lifecycle.native"), repo)

	var hookCount int
	var txActive bool
	var insertAlreadyRan bool
	var hookRecordID string
	if err := ps.RegisterRecordLifecycleHook(contract.RecordLifecycleRegistration{
		Capability: "test.lifecycle.native",
		Phase:      contract.RecordLifecycleAfterCreateInTx,
		Hook: func(ctx context.Context, event contract.RecordLifecycleEvent) error {
			hookCount++
			txActive = inlineTxManager{}.IsTransactionActive(ctx)
			insertAlreadyRan = repo.inserted != nil
			hookRecordID = event.RecordID
			return nil
		},
	}); err != nil {
		t.Fatalf("RegisterRecordLifecycleHook returned error: %v", err)
	}

	got, err := ps.Insert(context.Background(), "LifecycleProbe__c", models.SObject{
		constants.FieldName: "native-hook",
	}, recordEntrypointUser())
	if err != nil {
		t.Fatalf("Insert failed: %v", err)
	}
	if hookCount != 1 {
		t.Fatalf("native after-create hook count = %d, want 1", hookCount)
	}
	if !txActive {
		t.Fatal("native after-create hook did not run inside the write transaction")
	}
	if !insertAlreadyRan {
		t.Fatal("native after-create hook ran before repo.Insert; want post-SQL hook point")
	}
	if hookRecordID == "" || hookRecordID != got[constants.FieldID] {
		t.Fatalf("hook RecordID = %q, result Id = %v", hookRecordID, got[constants.FieldID])
	}
}

func TestNativeLifecycleAfterCreateFailureReturnsHookError(t *testing.T) {
	repo := &captureRecordRepo{}
	ps := newRecordEntrypointTestService(lifecycleCapabilitySchema("test.lifecycle.native"), repo)

	sentinel := errors.New("native lifecycle failed")
	if err := ps.RegisterRecordLifecycleHook(contract.RecordLifecycleRegistration{
		Capability: "test.lifecycle.native",
		Phase:      contract.RecordLifecycleAfterCreateInTx,
		Hook: func(context.Context, contract.RecordLifecycleEvent) error {
			return sentinel
		},
	}); err != nil {
		t.Fatalf("RegisterRecordLifecycleHook returned error: %v", err)
	}

	_, err := ps.Insert(context.Background(), "LifecycleProbe__c", models.SObject{
		constants.FieldName: "native-hook",
	}, recordEntrypointUser())
	if !errors.Is(err, sentinel) {
		t.Fatalf("Insert error = %v, want sentinel native lifecycle error", err)
	}
	if repo.inserted == nil {
		t.Fatal("repo.Insert did not run before after-create hook failure; this test must cover post-SQL rollback semantics")
	}
}

func TestExternalLifecycleHookRegistrationRunsAfterCreateInTransaction(t *testing.T) {
	repo := &captureRecordRepo{}
	ps := newRecordEntrypointTestService(lifecycleCapabilitySchema("test.lifecycle.probe"), repo)

	var hookCount int
	var txActive bool
	var insertAlreadyRan bool
	var hookRecordID string
	var hookRecordName string
	err := ps.RegisterRecordLifecycleHook(contract.RecordLifecycleRegistration{
		Capability: "test.lifecycle.probe",
		Phase:      contract.RecordLifecycleAfterCreateInTx,
		Hook: func(ctx context.Context, event contract.RecordLifecycleEvent) error {
			hookCount++
			txActive = inlineTxManager{}.IsTransactionActive(ctx)
			insertAlreadyRan = repo.inserted != nil
			hookRecordID = event.RecordID
			hookRecordName, _ = event.Record[constants.FieldName].(string)
			return nil
		},
	})
	if err != nil {
		t.Fatalf("RegisterRecordLifecycleHook returned error: %v", err)
	}

	got, err := ps.Insert(context.Background(), "LifecycleProbe__c", models.SObject{
		constants.FieldName: "external-hook",
	}, recordEntrypointUser())
	if err != nil {
		t.Fatalf("Insert failed: %v", err)
	}
	if hookCount != 1 {
		t.Fatalf("external after-create hook count = %d, want 1", hookCount)
	}
	if !txActive {
		t.Fatal("external after-create hook did not run inside the write transaction")
	}
	if !insertAlreadyRan {
		t.Fatal("external after-create hook ran before repo.Insert; want post-SQL hook point")
	}
	if hookRecordID == "" || hookRecordID != got[constants.FieldID] {
		t.Fatalf("hook RecordID = %q, result Id = %v", hookRecordID, got[constants.FieldID])
	}
	if hookRecordName != "external-hook" {
		t.Fatalf("hook record Name = %q, want external-hook", hookRecordName)
	}
}

func TestExternalLifecycleHookCanBeSuppressedForExplicitAlternateOwner(t *testing.T) {
	repo := &captureRecordRepo{}
	ps := newRecordEntrypointTestService(lifecycleCapabilitySchema("test.lifecycle.probe"), repo)

	var hookCount int
	err := ps.RegisterRecordLifecycleHook(contract.RecordLifecycleRegistration{
		Capability: "test.lifecycle.probe",
		Phase:      contract.RecordLifecycleAfterCreateInTx,
		Hook: func(context.Context, contract.RecordLifecycleEvent) error {
			hookCount++
			return nil
		},
	})
	if err != nil {
		t.Fatalf("RegisterRecordLifecycleHook returned error: %v", err)
	}

	_, err = ps.Insert(contract.WithRecordLifecycleHooksSuppressed(context.Background()), "LifecycleProbe__c", models.SObject{
		constants.FieldName: "suppressed-hook",
	}, recordEntrypointUser())
	if err != nil {
		t.Fatalf("Insert failed: %v", err)
	}
	if hookCount != 0 {
		t.Fatalf("suppressed lifecycle hook count = %d, want 0", hookCount)
	}
}

func TestExternalLifecycleHookRegistrationFailsClosedWhenMetadataCapabilityHasNoHandler(t *testing.T) {
	repo := &captureRecordRepo{}
	ps := newRecordEntrypointTestService(lifecycleCapabilitySchema("test.lifecycle.probe"), repo)

	_, err := ps.Insert(context.Background(), "LifecycleProbe__c", models.SObject{
		constants.FieldName: "missing-handler",
	}, recordEntrypointUser())
	if err == nil {
		t.Fatal("Insert succeeded, want fail-closed missing lifecycle capability handler")
	}
	if !strings.Contains(err.Error(), "missing record lifecycle hook") ||
		!strings.Contains(err.Error(), "test.lifecycle.probe") {
		t.Fatalf("Insert error = %v, want missing hook for metadata lifecycle capability", err)
	}
	if repo.inserted == nil {
		t.Fatal("repo.Insert did not run before after-create hook failure; this test must cover post-SQL rollback semantics")
	}
}

func TestExternalLifecycleHookRegistrationRejectsDuplicateBinding(t *testing.T) {
	ps := newRecordEntrypointTestService(lifecycleSpikeSchema(), &captureRecordRepo{})
	reg := contract.RecordLifecycleRegistration{
		Capability: "test.lifecycle.probe",
		Phase:      contract.RecordLifecycleAfterCreateInTx,
		Hook:       func(context.Context, contract.RecordLifecycleEvent) error { return nil },
	}
	if err := ps.RegisterRecordLifecycleHook(reg); err != nil {
		t.Fatalf("first RegisterRecordLifecycleHook returned error: %v", err)
	}
	if err := ps.RegisterRecordLifecycleHook(reg); err == nil {
		t.Fatal("duplicate RegisterRecordLifecycleHook succeeded, want fail-closed error")
	}
}

func TestExternalLifecycleHookRegistrationRejectsInvalidRegistration(t *testing.T) {
	ps := newRecordEntrypointTestService(lifecycleSpikeSchema(), &captureRecordRepo{})
	tests := []struct {
		name string
		reg  contract.RecordLifecycleRegistration
	}{
		{
			name: "missing capability",
			reg: contract.RecordLifecycleRegistration{
				Phase: contract.RecordLifecycleAfterCreateInTx,
				Hook:  func(context.Context, contract.RecordLifecycleEvent) error { return nil },
			},
		},
		{
			name: "missing hook",
			reg: contract.RecordLifecycleRegistration{
				Capability: "test.lifecycle.probe",
				Phase:      contract.RecordLifecycleAfterCreateInTx,
			},
		},
		{
			name: "unsupported phase",
			reg: contract.RecordLifecycleRegistration{
				Capability: "test.lifecycle.probe",
				Phase:      contract.RecordLifecyclePhase("before_create"),
				Hook:       func(context.Context, contract.RecordLifecycleEvent) error { return nil },
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if err := ps.RegisterRecordLifecycleHook(tt.reg); err == nil {
				t.Fatal("RegisterRecordLifecycleHook succeeded, want error")
			}
		})
	}
}

func profileLifecycleSchema() *models.ObjectMetadata {
	schema := &models.ObjectMetadata{
		ID:           "obj_profile",
		APIName:      constants.TableProfile,
		Label:        "Profile",
		SharingModel: constants.SharingModelPublicReadWrite,
		AccessPolicy: profileLifecycleAccessPolicy(),
		Fields: []models.FieldMetadata{
			{APIName: constants.FieldID, Type: constants.FieldTypeString, Createable: false, Updateable: false},
			{APIName: constants.FieldName, Type: constants.FieldTypeString, Createable: true, Updateable: true, Required: true},
			{APIName: "Description", Type: constants.FieldTypeString, Createable: true, Updateable: true},
			{APIName: constants.FieldIsDeleted, Type: constants.FieldTypeBoolean, Createable: false, Updateable: false},
		},
	}
	makeFieldsMap(schema)
	return schema
}

func profileLifecycleAccessPolicy() *models.ObjectAccessPolicy {
	return &models.ObjectAccessPolicy{
		WriteValidation: &models.RecordWriteValidationAccessPolicy{
			Validators: []models.RecordWriteValidationPolicy{
				{Phase: string(contract.RecordWriteValidationBeforeDelete), Capability: securityWriteValidateSoftDeleteReferenceGuardCapability},
			},
		},
		Lifecycle: &models.RecordLifecycleAccessPolicy{
			Hooks: []models.RecordLifecycleHookPolicy{
				{Phase: string(contract.RecordLifecycleAfterCreateInTx), Capability: profilePermissionCarrierLifecycleCapability},
				{Phase: string(contract.RecordLifecycleAfterUpdateInTx), Capability: profilePermissionCarrierLifecycleCapability},
				{Phase: string(contract.RecordLifecycleAfterDeleteInTx), Capability: profilePermissionCarrierLifecycleCapability},
			},
		},
	}
}

func newProfileLifecycleTestService(repo *captureRecordRepo, permRepo *stubUpsertPermRepo) *PersistenceService {
	ps := newRecordEntrypointTestService(profileLifecycleSchema(), repo)
	ps.permissions = permissions.NewPermissionService(permRepo, ps.metadata, nil)
	ps.lifecycleHooks = newRecordLifecycleHooks()
	ps.registerProfileLifecycleHooks()
	return ps
}

func TestProfilePermissionCarrierLifecycleOwnsPessimisticBoundary(t *testing.T) {
	ps := newProfileLifecycleTestService(&captureRecordRepo{}, &stubUpsertPermRepo{})
	schema := profileLifecycleSchema()
	for _, phase := range []recordLifecyclePhase{
		recordLifecycleAfterCreateInTx,
		recordLifecycleAfterUpdateInTx,
		recordLifecycleAfterDeleteInTx,
	} {
		if !ps.recordLifecyclePhaseRequiresPessimisticTransaction(
			context.Background(),
			schema,
			phase,
		) {
			t.Fatalf("%s does not require a pessimistic transaction", phase)
		}
	}
}

func TestProfileLifecycleCreateSeedsPermissionsAndNavigationFromRecordsHook(t *testing.T) {
	repo := &captureRecordRepo{}
	permRepo := &stubUpsertPermRepo{grantCreate: true, grantEdit: true, grantDelete: true}
	ps := newProfileLifecycleTestService(repo, permRepo)

	got, err := ps.Insert(context.Background(), constants.TableProfile, models.SObject{
		constants.FieldName: "Ops Profile",
	}, recordEntrypointUser())
	if err != nil {
		t.Fatalf("Insert(Profile) failed: %v", err)
	}
	profileID, _ := got[constants.FieldID].(string)
	if profileID == "" {
		t.Fatalf("Insert(Profile) returned no Id: %#v", got)
	}
	if permRepo.ensureProfilePermissionSetCalls != 1 ||
		len(permRepo.ensureProfilePermissionSetIDs) != 1 ||
		permRepo.ensureProfilePermissionSetIDs[0] != profileID ||
		permRepo.ensureProfilePermissionSetNames[0] != "Ops Profile" {
		t.Fatalf("EnsureProfilePermissionSet calls = %d ids=%v names=%v, want one call for inserted profile",
			permRepo.ensureProfilePermissionSetCalls, permRepo.ensureProfilePermissionSetIDs, permRepo.ensureProfilePermissionSetNames)
	}
	if len(permRepo.ensureNavigationProfileIDs) != 1 || permRepo.ensureNavigationProfileIDs[0] != profileID {
		t.Fatalf("EnsureNavigationVisibilityForProfile ids = %v, want [%s]", permRepo.ensureNavigationProfileIDs, profileID)
	}
}

func TestProfileLifecycleUpdateRunsNavigationHookForNoopPatch(t *testing.T) {
	repo := &captureRecordRepo{old: models.SObject{
		constants.FieldID:   "profile-1",
		constants.FieldName: "Ops Profile",
	}}
	permRepo := &stubUpsertPermRepo{grantCreate: true, grantEdit: true, grantDelete: true}
	ps := newProfileLifecycleTestService(repo, permRepo)

	if err := ps.Update(context.Background(), constants.TableProfile, "profile-1", models.SObject{}, recordEntrypointUser()); err != nil {
		t.Fatalf("Update(Profile no-op) failed: %v", err)
	}
	if len(permRepo.ensureNavigationProfileIDs) != 1 || permRepo.ensureNavigationProfileIDs[0] != "profile-1" {
		t.Fatalf("EnsureNavigationVisibilityForProfile ids = %v, want [profile-1]", permRepo.ensureNavigationProfileIDs)
	}
	if repo.updated != nil {
		t.Fatalf("repo.Update = %#v, want nil for no-op update", repo.updated)
	}
}

func TestProfileLifecycleDeleteCleansOwnedPermissionSetFromRecordsHook(t *testing.T) {
	repo := &captureRecordRepo{old: models.SObject{
		constants.FieldID:        "profile-1",
		constants.FieldName:      "Ops Profile",
		constants.FieldIsDeleted: false,
	}}
	permRepo := &stubUpsertPermRepo{grantCreate: true, grantEdit: true, grantDelete: true}
	ps := newProfileLifecycleTestService(repo, permRepo)

	if err := ps.Delete(context.Background(), constants.TableProfile, "profile-1", recordEntrypointUser()); err != nil {
		t.Fatalf("Delete(Profile) failed: %v", err)
	}
	if len(permRepo.cleanupProfilePermissionSetIDs) != 1 || permRepo.cleanupProfilePermissionSetIDs[0] != "profile-1" {
		t.Fatalf("CleanupProfilePermissionSet ids = %v, want [profile-1]", permRepo.cleanupProfilePermissionSetIDs)
	}
	if repo.updated == nil || repo.updated[constants.FieldIsDeleted] != 1 {
		t.Fatalf("repo.Update soft-delete = %#v, want IsDeleted=1", repo.updated)
	}
}
