package services

import (
	"context"
	"fmt"
	"time"

	"github.com/nexuscrm/analytics"
	"github.com/nexuscrm/auth"
	"github.com/nexuscrm/automation"
	"github.com/nexuscrm/automation/autonumber"
	"github.com/nexuscrm/automation/standardaction"
	"github.com/nexuscrm/backend/internal/bootstrap"
	"github.com/nexuscrm/events"
	"github.com/nexuscrm/kernel/pkg/flowactions"
	"github.com/nexuscrm/kernel/pkg/formula"
	"github.com/nexuscrm/metadata"
	"github.com/nexuscrm/outbound/email"
	"github.com/nexuscrm/outbound/externaldelivery"
	http_client "github.com/nexuscrm/outbound/http_client"
	"github.com/nexuscrm/outbound/url_safety"
	"github.com/nexuscrm/permissions"
	"github.com/nexuscrm/persistence"
	"github.com/nexuscrm/records"
	"github.com/nexuscrm/shared/pkg/contract"
)

var _ records.SelectedRootProjectionReader = (*analytics.QueryService)(nil)

// Initialization Helpers

func (sm *ServiceManager) initInfrastructure() {
	sm.TxManager = sm.deps.TxManager

	// Use injected EventBus if provided (headless control-plane mode),
	// otherwise create a real EventBus with metrics (runtime server mode).
	if sm.deps.EventBus != nil {
		sm.EventBus = sm.deps.EventBus
	} else {
		realBus := events.NewEventBus()
		realBus.SetMetricsCallbacks(events.MetricsCallbacks{
			OnEventDropped:           sm.deps.OnEventDropped,
			OnQueueUtilizationChange: sm.deps.OnQueueUtilizationChange,
		})
		sm.EventBus = realBus
	}

	formulaEngine := formula.NewEngine()
	sm.Validation = metadata.NewValidationService(formulaEngine)
	sm.Validation.SetFormulaEngineResolver(func(ctx context.Context) (*formula.Engine, error) {
		view, bound := metadata.RuntimeMetadataViewFromContext(ctx)
		if !bound {
			// Legacy and control-plane compositions retain their published static
			// engine. Independent Runtime binds an exact view before effects.
			return formulaEngine, nil
		}
		if !view.SupportsMutationClosure() {
			return nil, fmt.Errorf("request-bound Runtime view cannot authorize record validation")
		}
		return view.NewFormulaEngine()
	})
	sm.EmailSender = email.NewEmailSenderFromEnv(sm.Logger)
}

func (sm *ServiceManager) initRepositories() {
	sm.UserRepo = sm.deps.UserRepo
	sm.SystemRepo = sm.deps.SystemRepo
	sm.BulkRepo = sm.deps.BulkRepo
}

func (sm *ServiceManager) initCoreDomain() {
	sm.Schema = metadata.NewSchemaManager(sm.deps.SchemaRepo)

	sm.Metadata = metadata.NewMetadataService(sm.deps.MetadataRepo, sm.Schema, sm.EventBus)
	sm.Metadata.SetTransactionManager(sm.TxManager)
	sm.NeutralMetadata = metadata.NewContractService(sm.Metadata, sm.roleAncestorsSource())

	// Circular dependency resolution
	sm.Metadata.SetValidationService(sm.Validation)
	sm.Validation.SetFormulaRelationResolver(metadata.NewValidationFormulaRelationResolver(sm.Metadata, sm.deps.RecordRepo))
	sm.Validation.SetActiveProjectionResolver(metadata.NewValidationActiveProjectionResolver(sm.Metadata, sm.deps.QueryRepo))

	// Wire MetadataService as the single schema cache authority for RecordRepository,
	// eliminating the duplicate sync.Map cache that RecordRepository previously maintained.
	if rr, ok := sm.deps.RecordRepo.(*persistence.RecordRepository); ok {
		rr.SetSchemaProvider(sm.Metadata)
	}
}

func (sm *ServiceManager) initAuthStack() {
	var permissionOptions []permissions.PermissionServiceOption
	if limits := sm.deps.FieldProjectionAdmission; limits != nil {
		permissionOptions = append(permissionOptions, permissions.WithFieldProjectionAdmissionLimits(limits.Active, limits.Waiters))
	}
	sm.Permissions = permissions.NewPermissionService(sm.deps.PermissionRepo, sm.Metadata, sm.UserRepo, permissionOptions...)
	if listViewRepo, ok := sm.deps.MetadataRepo.(metadata.ListViewRepository); ok {
		sm.ListViews = metadata.NewListViewService(listViewRepo, sm.Permissions)
	}
	sm.Metadata.SetListViewRLSProvider(sm.Permissions)
	sm.NeutralPolicy = permissions.NewContractPolicy(sm.Permissions)

	// Register the schema decorator that synthesizes PermissionSet's
	// Permissions<X> virtual fields from the _System_Permission catalog.
	// Must run after PermissionService is constructed; registration
	// invalidates the metadata cache so the next GetSchema rebuilds
	// through this decorator.
	sm.Metadata.RegisterSchemaDecorator(permissions.NewSchemaDecorator(sm.Permissions))
	sm.Metadata.RegisterSchemaDecorator(metadata.NewCompoundVirtualFieldDecorator(metadata.PlatformCompoundVirtualFieldDeclarations()...))
	// Registers platform-floor defaultedOnCreate declarations so the records-layer
	// `applyUserDefaults` can synthesize a reset-flow placeholder without the
	// REST `validateRequiredForCreate` rejecting first. Surface is
	// describe-only; storage / edit-path semantics are unchanged.
	// Issue #1425.
	sm.Metadata.RegisterSchemaDecorator(metadata.NewServerDefaultedFieldsDecorator(metadata.PlatformServerDefaultedFieldDeclarations()...))

	sm.Auth = auth.NewAuthService(sm.UserRepo, sm.deps.SessionRepo, sm.deps.PermissionRepo)
	sm.Auth.SetPermissionService(sm.Permissions)
	sm.Auth.SetLoginHistoryRepo(persistence.NewLoginHistoryRepository(sm.deps.DB))

	setupAuditRepo := persistence.NewSetupAuditRepository(sm.deps.DB)
	sm.SetupAuditor = auth.NewSetupAuditor(setupAuditRepo)
	sm.Auth.SetSetupAuditor(sm.SetupAuditor)
	sm.Permissions.SetSetupAuditor(sm.SetupAuditor)

	sm.OAuth = auth.NewOAuthService(sm.UserRepo, sm.SystemRepo, sm.deps.SessionRepo, sm.Auth, sm.deps.OAuthRepo)
	sm.OAuth.SetDisconnectAuditRecorder(setupAuditRepo)
	sm.ApiToken = auth.NewApiTokenService(sm.deps.ApiTokenRepo, sm.Auth)

	// Fold OAuth refresh-token revocation into credential revocation, so a
	// password change/reset kills refresh tokens too — a stolen refresh token
	// must not survive the session revocation.
	sm.Auth.AddCredentialRevocationHook(sm.OAuth.RevokeRefreshTokensForUser)

	// FolderAccessService: dedicated folder permission enforcement (Phase 1, #697)
	sm.FolderAccess = permissions.NewFolderAccessService(sm.Permissions, sm.deps.DB)
	sm.Permissions.SetFolderAccess(sm.FolderAccess)
}

func (sm *ServiceManager) initAnalyticsStack() {
	// Initialize FormulaViewService for TiDB View-based formula computation.
	sm.FormulaViewSvc = analytics.NewFormulaViewService(sm.deps.DB, sm.Metadata, sm.Logger)
	sm.Metadata.SetFormulaViewRebuilder(sm.FormulaViewSvc)
	sm.Metadata.SetValidationProjectionInvalidator(sm.FormulaViewSvc)
	sm.Metadata.RegisterCacheInvalidationHook(sm.FormulaViewSvc.InvalidateAllValidationProjections)
	sm.Validation.SetActiveFormulaResolver(analytics.NewValidationActiveFormulaResolver(sm.FormulaViewSvc, sm.deps.QueryRepo))
	sm.Validation.SetValidationProjectionProvider(sm.FormulaViewSvc)
	metadata.SetAggregateFormulaDetector(func(expr string) (bool, error) {
		aggregates, err := analytics.FindAggregateAuthoringExprs(expr)
		return len(aggregates) > 0, err
	})

	// Wire view resolver into QueryRepository so read queries route to v_{object} views
	if qr, ok := sm.deps.QueryRepo.(*persistence.QueryRepository); ok {
		qr.SetViewResolver(sm.FormulaViewSvc)
	}
	// Also wire the resolver into RecordRepository so single-record reads
	// (FindOne, used by RetrieveWithAccess and L1 UI record assembly) route through
	// v_{object} when active formulas exist. Without this, /ui-api/record-ui
	// returns NULL for every formula column on the detail page.
	if rr, ok := sm.deps.RecordRepo.(*persistence.RecordRepository); ok {
		rr.SetViewResolver(sm.FormulaViewSvc)
	}
	// Rollup aggregation must also read through the authoritative view when the
	// child summary field is active-computed; otherwise SUM/AVG over a child
	// formula/projection field hits the raw table snapshot and misses the
	// computed value entirely.
	if rr, ok := sm.deps.RollupRepo.(*persistence.RollupRepository); ok {
		rr.SetSchemaProvider(sm.Metadata)
		rr.SetViewResolver(sm.FormulaViewSvc)
	}

	querySvc := analytics.NewQueryService(sm.deps.QueryRepo, sm.Metadata, sm.Permissions, sm.Logger)
	querySvc.SetHistoryBackend(sm.deps.DB, sm.FormulaViewSvc)
	querySvc.SetHistoryFactory(auditHistoryQueryFactory{})
	sm.QuerySvc = querySvc
	sm.NeutralQuery = querySvc
	sm.Validation.SetRecordSetBindingResolver(metadata.NewValidationRecordSetBindingResolver(
		sm.Metadata,
		sm.QuerySvc,
		metadata.NewValidationFormulaRelationResolver(sm.Metadata, sm.deps.RecordRepo),
	))

	// CRT wiring (design §4.9) — auto-scan standard objects + construct the
	// CRT resolver so Custom Report Types resolve through a real
	// *JoinTemplate instead of returning 404.
	stdObjs := analytics.BuildStandardObjectResolver(context.Background(), sm.Metadata, sm.Logger)
	crtLoader := analytics.NewCRTLoader(sm.deps.DB, sm.Logger)
	crtResolver := analytics.NewCRTResolver(crtLoader, sm.Metadata, stdObjs, sm.Logger)
	sm.ReportSvc = analytics.NewReportService(
		sm.QuerySvc, sm.Metadata, sm.Permissions, sm.Logger,
		analytics.WithCRTResolver(crtResolver),
		analytics.WithStandardObjectResolver(stdObjs),
		analytics.WithRawDB(sm.deps.DB),
	)
	sm.Rollup = analytics.NewRollupService(sm.deps.RollupRepo, sm.Metadata, sm.TxManager, sm.Logger)

	sm.SecurityValidator = analytics.NewSecurityValidator(sm.Permissions, sm.Metadata)

	// Subscribe to schema change events to auto-rebuild formula views
	formulaViewListener := analytics.NewFormulaViewListener(sm.FormulaViewSvc, sm.EventBus)
	formulaViewListener.Subscribe()
}

func (sm *ServiceManager) initAutomationStack() error {
	// MetadataLookup arms the dynamic side of the OutboxService's
	// no-subscriber object filter. Automation owns FlowInterview and registers
	// it here; products may add their own objects through product hooks. Tests
	// that bypass ServiceManager wire-up may leave the lookup unset, in which
	// case the filter falls through to the safe enqueue path (issue #1658).
	events.RegisterDefaultNoSubscriberRecordEventObjects(automation.NoSubscriberRecordEventObjects()...)
	sm.Outbox = events.NewOutboxService(sm.deps.OutboxRepo, sm.EventBus, sm.TxManager).
		WithMetadataLookup(sm.Metadata).
		WithWorkerRepository(sm.deps.OutboxWorkerRepo).
		WithRecordEventRepositories(sm.deps.RecordEventOutboxRepo, sm.deps.RecordEventOutboxWorkerRepo)

	// Autonumber v2 engine + metadata source (PRD §3.1 / §7.2 step k).
	// Replaces the legacy CompoundAutoNumberListener — the engine is
	// synchronously invoked from PersistenceService.Insert / BulkInsert
	// before RecordBeforeCreate publishes, so flows/triggers still see
	// the rendered identifier.
	//
	// Round-5 Codex V2-C2 fail-closed: the previous implementation
	// logged the error and continued. An empty MetadataSource snapshot
	// causes LookupByObject to return nil for every object, which the
	// persistence layer reads as "no autonumber configured" and
	// silently skips assignment — turning a transient DB blip at init
	// into permanently missing identifiers until the pod restarts on
	// an unrelated trigger. We now retry with exponential backoff (5×
	// starting at 500 ms: 500 ms, 1 s, 2 s, 4 s, 8 s ≈ 15.5 s total)
	// and propagate the terminal error so the process aborts before
	// serving traffic.
	sm.AutoNumberMetadata = autonumber.NewMetadataSource(sm.deps.DB, nil)
	if err := sm.AutoNumberMetadata.RefreshWithBackoff(context.Background(), 5, 500*time.Millisecond); err != nil {
		return fmt.Errorf("autonumber metadata init failed: %w", err)
	}
	// Wire the invalidator into MetadataService so admin field-create
	// paths (field_create.go + field_batch.go) can nudge the engine's
	// snapshot after writing AutoNumberFormat. Round-5 Codex
	// V1-P2 / V2-H3.
	sm.Metadata.SetAutoNumberInvalidator(sm.AutoNumberMetadata)
	// Let validation, schema projection, and layout assembly consume one
	// `_System_Field.auto_number_format` read. The schema decorator below marks
	// these fields DefaultedOnCreate in the effective metadata cache, so v63
	// describe and create-required validation read the same field-level fact.
	autoNumberFieldResolver := func(objectName string) map[string]struct{} {
		fields := sm.AutoNumberMetadata.LookupByObject(objectName)
		if len(fields) == 0 {
			return nil
		}
		out := make(map[string]struct{}, len(fields))
		for _, fm := range fields {
			out[fm.FieldAPIName] = struct{}{}
		}
		return out
	}
	sm.Validation.SetAutoNumberResolver(autoNumberFieldResolver)
	// Let MetadataService exclude autonumber fields from editable layout
	// sections (issue #1104). Same closure shape as the Validation wire above.
	sm.Metadata.SetAutoNumberFieldResolver(autoNumberFieldResolver)
	sm.Metadata.RegisterSchemaDecorator(metadata.NewAutoNumberDefaultedFieldsDecorator(autoNumberFieldResolver))
	resolver := autonumber.NewRepoCrossRecordResolver(sm.deps.RecordRepo)
	engine, err := autonumber.NewEngine(
		autonumber.WithReservationDB(sm.deps.DB),
		autonumber.WithMetadataProvider(sm.AutoNumberMetadata),
		autonumber.WithCrossRecordResolver(resolver),
	)
	if err != nil {
		// Engine construction only fails on missing required option; we
		// wired both required opts above, so an error here is a
		// developer bug — panic rather than mask it.
		panic(fmt.Sprintf("autonumber engine init: %v", err))
	}
	sm.AutoNumberEngine = engine

	sm.Persistence = records.NewPersistenceService(
		sm.deps.RecordRepo,
		sm.Rollup,
		sm.Metadata,
		sm.Permissions,
		sm.EventBus,
		sm.Validation,
		sm.TxManager,
		sm.Outbox,
		sm.Logger,
		sm.AutoNumberEngine,
		sm.AutoNumberMetadata,
		sm.deps.SessionRepo, // session revocation on user/app deactivation
		sm.Auth,             // cache invalidation on user deactivation
	)
	assignmentPolicyAuthority, err := permissions.NewSQLAssignmentPolicyDirectWriteAuthority(sm.TxManager)
	if err != nil {
		return fmt.Errorf("assignment policy direct-write authority: %w", err)
	}
	if err := sm.Persistence.SetAssignmentPolicyDirectWriteAuthority(assignmentPolicyAuthority); err != nil {
		return err
	}
	if sm.deps.PermissionStateAuthority != nil {
		if err := sm.Persistence.SetPermissionStateAuthority(sm.deps.PermissionStateAuthority); err != nil {
			return err
		}
	}
	// Wire raw DB for the insert-inheritance applier's registry lookup
	// (reads _System_ObjectFieldMapping rows with ActionType =
	// 'insertInheritFromLookup'). Unwired ps.rawDB makes applyInsertInheritance
	// a silent no-op in production — greenfield is happy, tenant-overlay
	// rules never fire. Must run after NewPersistenceService.
	sm.Persistence.SetRawDB(sm.deps.DB)
	sm.Persistence.SetParentLaneAdmissionController(sm.deps.ParentLaneAdmission)
	sm.Persistence.SetCurrentUserPasswordLookup(sm.UserRepo)
	// A Connected App security-policy change or deletion that revokes the app's
	// sessions must also revoke its OAuth refresh tokens (same transaction), so a
	// token issued under the old policy cannot mint a fresh session afterward.
	if sm.deps.OAuthRepo != nil {
		sm.Persistence.SetConnectedAppRefreshTokenRevoker(sm.deps.OAuthRepo)
	}
	sm.Outbox.WithRecordPayloadLoader(sm.Persistence)
	// Wire QuerySvc for the recycle-bin LIST fan-out
	// (persistence_recyclebin_fanout.go). Other PersistenceService paths
	// do not depend on it; the recycle-bin path is the sole consumer.
	sm.Persistence.SetQueryService(sm.QuerySvc)
	selectedRoot, ok := sm.QuerySvc.(records.SelectedRootProjectionReader)
	if !ok {
		return fmt.Errorf("analytics query service does not implement selected-root projection")
	}
	sm.Persistence.SetSelectedRootProjectionReader(selectedRoot)
	sm.NeutralRecords = records.NewContractService(sm.Persistence)
	sm.metadataDeployRecordDeleter = records.NewMetadataDeployRecordDeleter(sm.Persistence)
	if sm.ListViews != nil {
		sm.NeutralListViewAuthoring = metadata.NewListViewAuthoringContract(sm.ListViews, sm.Persistence)
	}
	if sm.Metadata != nil {
		sm.Metadata.RegisterCacheInvalidationHook(sm.Persistence.InvalidateCreateFromSourceGuardCache)
		sm.Metadata.RegisterCacheInvalidationHook(sm.Persistence.InvalidateInsertInheritanceCache)
	}
	// Wire active-formula projector so Insert/Update/BulkInsert stamp formula-view
	// snapshots into outbox InternalMetadata for FlowTriggerListener.
	// Must run after QuerySvc and Metadata are constructed.
	sm.Persistence.SetActiveFormulaProjector(analytics.NewActiveFormulaProjectionAdapter(sm.QuerySvc))

	// Wire DerivedWriter so rollup recomputes route through the full records
	// save lifecycle (validation rules, audit, outbox). Post-construction to
	// avoid circular DI (PersistenceService already holds a RollupProcessor).
	sm.Rollup.SetDerivedWriter(sm.Persistence)

	urlAllowlist := url_safety.NewAllowlist()
	// webhookClient is proxy-aware (honors HTTP_PROXY/HTTPS_PROXY/NO_PROXY) for
	// admin-configured Flow webhook targets that may traverse a corp egress proxy.
	// outboundClient (direct-only, IP-pinned) is reserved for connector_callout.Engine
	// and will be wired here when the Engine's REST handler is registered.
	webhookClient := http_client.NewWebhookClient(urlAllowlist)

	sm.ActionSvc = automation.NewActionService(sm.Metadata, sm.Persistence, sm.Permissions, sm.TxManager, sm.EmailSender, webhookClient, urlAllowlist, sm.Logger)

	// Build the thin append-only delivery attempt store on the raw DB (outside
	// any caller transaction so evidence survives tx rollback — C7).
	sm.ExternalDelivery = externaldelivery.NewStore(sm.deps.DB, nil, nil)
	sm.ActionSvc.SetAttemptRecorder(sm.ExternalDelivery)
	// Mark the email transport as noop when SMTP_HOST is unset so evidence rows
	// record accurate delivery status (false success → StatusFailed with reason).
	if _, isNoop := sm.EmailSender.(*email.NoopSender); isNoop {
		sm.ActionSvc.SetEmailTransportNotDelivering(true)
	}

	sm.FlowInterviewSvc = automation.NewFlowInterviewService(sm.Persistence, sm.QuerySvc)
	if err := automation.ReserveRuntimeFlowActionCallTypes(); err != nil {
		return fmt.Errorf("flow action-call runtime reservations init failed: %w", err)
	}
	hostFlowActionCalls := append([]automation.FlowActionCallRegistration(nil), sm.deps.HostFlowActionCallRegistrations...)
	hostFlowActionCalls = append(hostFlowActionCalls, automation.DefaultAppFlowActionCallRegistrations()...)
	flowActionCalls, err := automation.EffectiveFlowActionCallRegistrations(hostFlowActionCalls...)
	if err != nil {
		return fmt.Errorf("flow action-call runtime registrations init failed: %w", err)
	}
	// Concrete connector field-mapping reader (bootstrap-hosted impl of the
	// runtimeports.ConnectorFieldMappingReader port). Constructed at the
	// composition root and injected into the automation feature root so that
	// automation depends only on the port, not on internal/bootstrap.
	connectorFieldMappingReader := bootstrap.NewConnectorFieldMappingReaderImpl(sm.deps.DB)
	// Concrete standard-action metadata reader (bootstrap-hosted impl of the
	// contract runtimeports.StandardActionReader port). Constructed here and
	// injected into the standard-action service so it depends only on the port,
	// not on internal/bootstrap.
	standardActionReader := bootstrap.NewStandardActionReaderImpl(sm.deps.DB)
	sm.FlowExecutor = automation.NewFlowExecutor(
		sm.Metadata,
		sm.ActionSvc,
		sm.FlowInterviewSvc,
		sm.Persistence,
		sm.QuerySvc,
		automation.NewBootstrapFieldMappingApplier(connectorFieldMappingReader),
		sm.Logger,
		automation.WithFlowActionCalls(flowActionCalls...),
		automation.WithConnectorMappingResolver(
			automation.NewBootstrapConnectorFieldMappingResolver(connectorFieldMappingReader),
		),
		automation.WithConnectorPushDurableStore(
			automation.NewSQLConnectorPushDurableStoreWithTxExtractor(sm.deps.DB, sm.TxManager),
		),
	)
	flowactions.Seal()
	sm.NeutralFlowExecutor = automation.NewContractFlowExecutor(sm.FlowExecutor)
	sm.NeutralStandardActions = standardaction.NewService(sm.deps.DB, sm.Permissions).WithRuntime(standardaction.RuntimeDeps{
		Metadata:               sm.Metadata,
		PreviewAccess:          sm.Permissions,
		Formulas:               formula.NewEngine(),
		Execute:                sm.NeutralFlowExecutor.ExecuteFlow,
		CreatePreview:          sm.FlowExecutor.PreviewStandardActionRecordCreateFields,
		OutputPreview:          sm.FlowExecutor.PreviewFlowOutputs,
		CreatePreviewSupported: sm.FlowExecutor.StandardActionCreatePreviewSupported,
		StandardActions:        standardActionReader,
	})
	sm.FlowExecutor.SetStandardActionContractDeps(contract.Deps{
		Records:                   sm.RecordsContract(),
		RecordMutationCoordinator: sm.RecordMutationCoordinatorContract(),
		Metadata:                  sm.MetadataContract(),
		Policy:                    sm.PolicyContract(),
		Notifications:             sm.NotificationsContract(),
	})

	approvalReplayStore := automation.NewSQLApprovalActionReplayStore(sm.deps.DB, sm.TxManager)
	sm.Approval = automation.NewApprovalService(
		sm.Persistence,
		sm.QuerySvc,
		sm.Permissions,
		sm.FlowExecutor,
		sm.FlowInterviewSvc,
		sm.ActionSvc,
		sm.Metadata,
		sm.Logger,
		automation.WithApprovalDefinitionDecoder(sm.deps.ApprovalDefinitionDecoder),
		automation.WithApprovalQueryOnlyOwnerPacks(sm.deps.ApprovalQueryOnlyOwnerPacks),
		automation.WithApprovalActionReplayStore(approvalReplayStore),
		automation.WithApprovalSubmitLineageStore(automation.NewSQLApprovalSubmitLineageStore(sm.deps.DB, sm.TxManager)),
		// NX#4150 Class C. sm.deps.DB is the pool handle, deliberately not a
		// transaction: the outcome row must survive the approval transaction it
		// describes.
		automation.WithApprovalActionLogSink(automation.NewDBApprovalActionLogStore(sm.deps.DB)),
	)
	// SubmitForApproval flow actionCall: the executor is constructed before the
	// approval service (which itself takes the executor for notification flows),
	// so the submit seam is late-bound here — same pattern as
	// SetPermissionService below.
	sm.FlowExecutor.SetApprovalSubmitter(sm.Approval)

	// $Permission in flow / filterFormula formulas resolves the running user's
	// effective permissions via the same PermissionService that validation
	// rules use (records/persistence_service). Without this wiring the
	// executor's provider stays nil and $Permission gates fail closed for
	// normal users (super-users still work via InjectSuperUserPermissions).
	if sm.Permissions != nil {
		sm.FlowExecutor.SetPermissionService(sm.Permissions)
	}

	// Record-triggered flow listener
	sm.FlowTriggerSvc = automation.NewFlowTriggerListener(sm.FlowExecutor, sm.Metadata, sm.EventBus)
	sm.FlowTriggerSvc.Subscribe()
	return nil
}

func (sm *ServiceManager) initBusinessServices() {
	sm.System = NewSystemManager(sm.Persistence, sm.SystemRepo)
	sm.System.SetSetupAuditor(sm.SetupAuditor)
	sm.PermissionAdmin = permissions.NewAdminService(sm.Permissions, sm.TxManager, permissions.AdminServiceOptions{})

	sm.BulkSvc = records.NewBulkService(sm.BulkRepo, sm.Logger)
}
