package automation

import (
	"context"
	"fmt"
	"reflect"
	"strings"
	"testing"

	apperrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/logging"
	"github.com/nexuscrm/metadata"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/nexuscrm/shared/pkg/queryspec"
)

type approvalFlowBridgePersistenceStub struct {
	records     map[string]models.SObject
	getRawCalls int
	allowGetRaw bool
}

func (p *approvalFlowBridgePersistenceStub) Insert(ctx context.Context, tableName string, record models.SObject, user *models.UserSession) (models.SObject, error) {
	panic("approvalFlowBridgePersistenceStub.Insert: not implemented")
}

func (p *approvalFlowBridgePersistenceStub) PreviewBeforeInsert(ctx context.Context, tableName string, record models.SObject, user *models.UserSession) (models.SObject, error) {
	panic("approvalFlowBridgePersistenceStub.PreviewBeforeInsert: not implemented")
}

func (p *approvalFlowBridgePersistenceStub) PrelockUpdateParents(ctx context.Context, tableName string, id string, updates models.SObject, user *models.UserSession) error {
	panic("approvalFlowBridgePersistenceStub.PrelockUpdateParents: not implemented")
}

func (p *approvalFlowBridgePersistenceStub) Update(ctx context.Context, tableName string, id string, updates models.SObject, user *models.UserSession) error {
	panic("approvalFlowBridgePersistenceStub.Update: not implemented")
}

func (p *approvalFlowBridgePersistenceStub) PreviewBeforeUpdate(ctx context.Context, tableName string, id string, updates models.SObject, internalStamps models.SObject, user *models.UserSession) (*models.PreparedRecordUpdate, error) {
	panic("approvalFlowBridgePersistenceStub.PreviewBeforeUpdate: not implemented")
}

func (p *approvalFlowBridgePersistenceStub) CommitPreparedUpdate(ctx context.Context, tableName string, id string, prepared *models.PreparedRecordUpdate, extraUpdates models.SObject, user *models.UserSession) error {
	panic("approvalFlowBridgePersistenceStub.CommitPreparedUpdate: not implemented")
}

func (p *approvalFlowBridgePersistenceStub) Delete(ctx context.Context, tableName string, id string, user *models.UserSession) error {
	panic("approvalFlowBridgePersistenceStub.Delete: not implemented")
}

func (p *approvalFlowBridgePersistenceStub) GetRaw(ctx context.Context, objectName string, id string) (models.SObject, error) {
	p.getRawCalls++
	if !p.allowGetRaw {
		return nil, fmt.Errorf("unexpected raw read for approval Flow notification target %s/%s", objectName, id)
	}
	key := objectName + "/" + id
	record := p.records[key]
	if record == nil {
		return nil, fmt.Errorf("record %s not found", key)
	}
	cloned := make(models.SObject, len(record))
	for k, v := range record {
		cloned[k] = v
	}
	return cloned, nil
}

func (p *approvalFlowBridgePersistenceStub) GetLock(ctx context.Context, tableName string, id string) (models.SObject, error) {
	panic("approvalFlowBridgePersistenceStub.GetLock: not implemented")
}

func (p *approvalFlowBridgePersistenceStub) GetRecordType(ctx context.Context, id string) (string, error) {
	panic("approvalFlowBridgePersistenceStub.GetRecordType: not implemented")
}

func (p *approvalFlowBridgePersistenceStub) RunInTransaction(ctx context.Context, fn func(context.Context) error) error {
	panic("approvalFlowBridgePersistenceStub.RunInTransaction: not implemented")
}

func (p *approvalFlowBridgePersistenceStub) IsTransactionActive(ctx context.Context) bool {
	return false
}

func (p *approvalFlowBridgePersistenceStub) RunInPessimisticTransaction(ctx context.Context, fn func(context.Context) error) error {
	panic("approvalFlowBridgePersistenceStub.RunInPessimisticTransaction: not implemented")
}

func (p *approvalFlowBridgePersistenceStub) DetachTx(ctx context.Context) context.Context {
	return ctx
}

func (p *approvalFlowBridgePersistenceStub) RecomputeActiveFormulaProjection(ctx context.Context, user *models.UserSession, objectAPIName, recordID string, fields []string) (models.SObject, error) {
	return nil, nil
}

type approvalFlowBridgeQueryStub struct {
	t         *testing.T
	records   map[string]models.SObject
	specErr   error
	specCalls []approvalFlowBridgeQueryCall
}

type approvalFlowBridgeQueryCall struct {
	objectName      string
	securityContext constants.SecurityContext
}

func (q *approvalFlowBridgeQueryStub) QueryWithSpec(ctx context.Context, req queryspec.QuerySpec, user *models.UserSession) (*models.QueryResult, error) {
	if q.specErr != nil {
		return nil, q.specErr
	}
	if req.ObjectAPIName == constants.TableWorkflowAlert {
		q.t.Fatalf("approval Flow bridge must not query legacy WorkflowAlert metadata")
	}
	if req.ObjectAPIName != "GenericObject" {
		q.t.Fatalf("approval Flow bridge queried %s, want GenericObject target read", req.ObjectAPIName)
	}
	// #4397: the snapshot read carries the engine's row authority and keeps
	// field-level security narrowed to EXPLICIT denials. The unnarrowed actor
	// projection this assertion previously demanded is why the production
	// incident survived the first cut of the fix: an actor denied object read
	// loses every field to the no-FLS-row fallback. Reverting to a plain user
	// read, elevating to SystemContext, dropping FLS entirely, or dropping the
	// narrowing must each fail here.
	if got := constants.GetSecurityContext(ctx); got != constants.UserContext {
		q.t.Fatalf("target read security context = %v, want UserContext with scoped access (engine authority via scope, not elevation)", got)
	}
	if !constants.IsScopedInternalAccess(ctx, req.ObjectAPIName, constants.PermRead) {
		q.t.Fatal("target read lacks the scoped internal-access row bypass")
	}
	if !constants.ScopedInternalAccessEnforcesFLS(ctx, req.ObjectAPIName, constants.PermRead) {
		q.t.Fatal("target read must keep field-level security on so explicitly denied fields stay out of notifications")
	}
	if !constants.ScopedInternalAccessIgnoresImplicitFieldDenials(ctx, req.ObjectAPIName, constants.PermRead) {
		q.t.Fatal("target read inherits the implicit field denial: a field with no FLS row falls back to the actor's object-level read, so an actor denied object read loses every field")
	}
	if constants.HasStandardActionProtectedFlowExecution(ctx) {
		q.t.Fatal("target read inherited protected standard-action Flow execution scope")
	}
	if req.Filter.Kind != queryspec.PredicateFieldComparison ||
		req.Filter.Field != constants.FieldID ||
		req.Filter.Op != "=" ||
		req.Filter.Value != "target-1" ||
		req.Limit != 1 {
		q.t.Fatalf("target read spec = %#v, want Id equality with limit 1", req)
	}
	q.specCalls = append(q.specCalls, approvalFlowBridgeQueryCall{
		objectName:      req.ObjectAPIName,
		securityContext: constants.GetSecurityContext(ctx),
	})
	record := q.records[req.ObjectAPIName+"/"+req.Filter.Value.(string)]
	if record == nil {
		return &models.QueryResult{Done: true}, nil
	}
	cloned := make(models.SObject, len(record))
	for k, v := range record {
		cloned[k] = v
	}
	return &models.QueryResult{
		TotalSize: 1,
		Done:      true,
		Records:   []models.SObject{cloned},
	}, nil
}

func (q *approvalFlowBridgeQueryStub) QueryWithFilter(ctx context.Context, objectName string, filterExpr string, user *models.UserSession, sortField string, sortDir string, limit int) (*models.QueryResult, error) {
	q.t.Fatalf("approval Flow bridge must not query %s with filter %q", objectName, filterExpr)
	return nil, nil
}

func (q *approvalFlowBridgeQueryStub) RunAnalytics(ctx context.Context, analyticsQuery models.AnalyticsQuery, currentUser *models.UserSession) (interface{}, error) {
	q.t.Fatalf("approval Flow bridge must not run analytics")
	return nil, nil
}

func (q *approvalFlowBridgeQueryStub) SearchSingleObject(ctx context.Context, objectName string, term string, searchFields []string, returnFields []string, limit int, offset int, orderBy string, orderDir string, filterExpr string, currentUser *models.UserSession) ([]models.SObject, error) {
	q.t.Fatalf("approval Flow bridge must not search %s", objectName)
	return nil, nil
}

func (q *approvalFlowBridgeQueryStub) SearchSuggestions(ctx context.Context, objectName string, term string, searchFields []string, returnFields []string, limit int, currentUser *models.UserSession) ([]models.SObject, error) {
	q.t.Fatalf("approval Flow bridge must not search suggestions for %s", objectName)
	return nil, nil
}

func (q *approvalFlowBridgeQueryStub) GlobalSearch(ctx context.Context, term string, currentUser *models.UserSession) ([]models.SearchResult, error) {
	q.t.Fatalf("approval Flow bridge must not global search")
	return nil, nil
}

func (q *approvalFlowBridgeQueryStub) Calculate(ctx context.Context, objectName string, record models.SObject, user *models.UserSession) (models.SObject, error) {
	q.t.Fatalf("approval Flow bridge must not calculate %s", objectName)
	return nil, nil
}

func (q *approvalFlowBridgeQueryStub) ExecuteRawSQL(ctx context.Context, sql string, params []interface{}, user *models.UserSession) ([]models.SObject, error) {
	q.t.Fatalf("approval Flow bridge must not execute raw SQL")
	return nil, nil
}

type approvalFlowBridgeExecutorStub struct {
	metadata       models.FlowMetadata
	metadataNil    bool
	metadataErr    error
	actionRegistry *flowActionCallRegistry
	result         *models.FlowExecutionResult
	err            error
	metadataCalls  []approvalFlowBridgeCall
	executionCalls []approvalFlowBridgeCall
}

type approvalFlowBridgeCall struct {
	flowAPIName     string
	inputs          map[string]interface{}
	userID          string
	securityContext constants.SecurityContext
}

func (e *approvalFlowBridgeExecutorStub) approvalNotificationFlowVersion(ctx context.Context, flowAPIName string, user *models.UserSession) (*models.FlowVersion, error) {
	userID := ""
	if user != nil {
		userID = user.ID
	}
	e.metadataCalls = append(e.metadataCalls, approvalFlowBridgeCall{
		flowAPIName:     flowAPIName,
		userID:          userID,
		securityContext: constants.GetSecurityContext(ctx),
	})
	if e.metadataErr != nil {
		return nil, e.metadataErr
	}
	if e.metadataNil {
		return nil, nil
	}
	metadata := e.metadata
	return &models.FlowVersion{
		Id:       "flow-version-1",
		Metadata: metadata,
	}, nil
}

func (e *approvalFlowBridgeExecutorStub) validateApprovalNotificationFlowMetadata(flowAPIName string, meta *models.FlowMetadata) error {
	return validateApprovalNotificationFlowMetadataWithRegistry(flowAPIName, meta, e.actionRegistry)
}

func (e *approvalFlowBridgeExecutorStub) executeApprovalNotificationFlowVersion(ctx context.Context, flowAPIName string, flowVersion *models.FlowVersion, inputs map[string]interface{}, user *models.UserSession) (*models.FlowExecutionResult, error) {
	userID := ""
	if user != nil {
		userID = user.ID
	}
	e.executionCalls = append(e.executionCalls, approvalFlowBridgeCall{
		flowAPIName:     flowAPIName,
		inputs:          inputs,
		userID:          userID,
		securityContext: constants.GetSecurityContext(ctx),
	})
	if e.err != nil {
		return nil, e.err
	}
	if e.result != nil {
		return e.result, nil
	}
	return &models.FlowExecutionResult{ActionName: flowAPIName, IsSuccess: true}, nil
}

func TestApprovalService_ProcessActionAlertFailsClosedAfterWorkflowAlertCutover(t *testing.T) {
	persistence := &approvalFlowBridgePersistenceStub{records: map[string]models.SObject{
		"GenericObject/target-1": {constants.FieldID: "target-1"},
	}}
	sender := &recordingEmailSender{}
	svc := &ApprovalService{
		persistence: persistence,
		query:       &approvalFlowBridgeQueryStub{t: t},
		actions: &ActionService{
			emailSender: sender,
			logger:      logging.New(context.Background()),
		},
		logger: testApprovalLogger(),
	}
	pending := pendingNotifications{
		actions: []models.SObject{
			{
				"Name":                               "Legacy WorkflowAlert",
				"ActionType":                         constants.ProcessActionTypeAlert,
				constants.FieldProcessAction_APIName: "Account.Legacy_Alert",
			},
			{
				"Name":            "Legacy Inline Alert",
				"ActionType":      constants.ProcessActionTypeAlert,
				"EmailTemplateId": "tmpl-legacy-inline",
			},
		},
		objectApiName: "GenericObject",
		targetID:      "target-1",
		actorIDs:      []string{"005-actor"},
	}

	failures := svc.executeProcessActionNotifications(context.Background(), pending, &models.UserSession{ID: "005-user"})
	if len(failures) != 2 {
		t.Fatalf("failures = %#v, want two cutover failures", failures)
	}
	for _, failure := range failures {
		if failure.ActionType != constants.ProcessActionTypeAlert {
			t.Fatalf("failure ActionType = %q, want Alert", failure.ActionType)
		}
		for _, want := range []string{"WorkflowAlert runtime dispatch is disabled", "Flow sendEmail", "remove it"} {
			if !strings.Contains(failure.Reason, want) {
				t.Fatalf("failure Reason = %q, want mention %q", failure.Reason, want)
			}
		}
	}
	if persistence.getRawCalls != 0 {
		t.Fatalf("GetRaw calls = %d, want 0 for disabled ProcessAction.Alert", persistence.getRawCalls)
	}
	if len(sender.messages) != 0 {
		t.Fatalf("email sender calls = %d, want 0 for disabled ProcessAction.Alert", len(sender.messages))
	}
}

func TestApprovalService_ProcessActionFlowLaunchesFlowWithApprovalContext(t *testing.T) {
	persistence := &approvalFlowBridgePersistenceStub{records: map[string]models.SObject{
		"GenericObject/target-1": {
			constants.FieldID: "target-1",
			"Secret__c":       "raw value must not enter Flow inputs",
		},
	}}
	query := &approvalFlowBridgeQueryStub{t: t, records: map[string]models.SObject{
		"GenericObject/target-1": {
			constants.FieldID: "target-1",
			"Name":            "Target record",
		},
	}}
	flow := &approvalFlowBridgeExecutorStub{metadata: models.FlowMetadata{
		ActionCalls: []models.FlowActionCall{
			{Name: "ResolveApprovers", ActionType: flowResolveEmailRecipientsActionType},
			{Name: "SendLegacyAlias", ActionType: "emailSimple"},
			{Name: "SendNotification", ActionType: flowSendEmailActionType},
		},
	}, actionRegistry: newFlowActionCallRegistry(testRuntimeFlowActionCallRegistrations()...)}
	targetSnapshot := cloneApprovalNotificationRecord(query.records["GenericObject/target-1"])
	models.StampObjectType(targetSnapshot, "GenericObject")
	svc := &ApprovalService{
		persistence:  persistence,
		query:        query,
		flowExecutor: flow,
		logger:       testApprovalLogger(),
	}

	pending := pendingNotifications{
		actions: []models.SObject{{
			"Name":                               "Notify submit",
			constants.FieldProcessAction_APIName: "Approval_Submit_Notification",
			"ActionType":                         constants.ProcessActionTypeFlow,
			"EmailTemplateId":                    "must-not-be-read",
		}},
		objectApiName:      "GenericObject",
		targetID:           "target-1",
		actorIDs:           []string{"legacy-inline-alert-recipient"},
		flowTargetSnapshot: targetSnapshot,
		approval: approvalFlowNotificationContext{
			ActionEvent:         "Submit",
			ProcessDefinitionID: "pd-1",
			ProcessInstanceID:   "pi-1",
			WorkItemIDs:         []string{"wi-1", "wi-2"},
			TargetObjectAPIName: "GenericObject",
			TargetRecordID:      "target-1",
			SubmitterUserID:     "submitter-1",
			ActorUserID:         "submitter-1",
			ApproverUserIDs:     []string{"approver-1", "approver-2"},
			Comments:            "please approve",
		},
	}

	systemCtx := constants.WithSecurityContext(context.Background(), constants.SystemContext, "approval transaction")
	failures := svc.executeProcessActionNotifications(systemCtx, pending, &models.UserSession{ID: "submitter-1"})
	if len(failures) != 0 {
		t.Fatalf("executeProcessActionNotifications failures = %#v, want none", failures)
	}
	if persistence.getRawCalls != 0 {
		t.Fatalf("GetRaw calls = %d, want 0 for approval Flow notifications", persistence.getRawCalls)
	}
	if len(query.specCalls) != 0 {
		t.Fatalf("post-commit target query calls = %d, want 0 because pending notifications carry a snapshot", len(query.specCalls))
	}
	if len(flow.metadataCalls) != 1 {
		t.Fatalf("Flow metadata calls = %d, want 1", len(flow.metadataCalls))
	}
	if flow.metadataCalls[0].securityContext != constants.UserContext {
		t.Fatalf("metadata security context = %v, want UserContext", flow.metadataCalls[0].securityContext)
	}
	if len(flow.executionCalls) != 1 {
		t.Fatalf("Flow calls = %d, want 1", len(flow.executionCalls))
	}
	call := flow.executionCalls[0]
	if call.flowAPIName != "Approval_Submit_Notification" {
		t.Fatalf("flowAPIName = %q, want Approval_Submit_Notification", call.flowAPIName)
	}
	if call.userID != "submitter-1" {
		t.Fatalf("flow userID = %q, want submitter-1", call.userID)
	}
	if call.securityContext != constants.UserContext {
		t.Fatalf("Flow execution security context = %v, want UserContext", call.securityContext)
	}
	record, ok := call.inputs["$Record"].(models.SObject)
	if !ok {
		t.Fatalf("$Record input = %#v, want SObject", call.inputs["$Record"])
	}
	if got := record["Name"]; got != "Target record" {
		t.Fatalf("$Record.Name = %#v, want Target record", got)
	}
	// Secret__c exists only in the raw-persistence fixture; together with the
	// getRawCalls==0 assertion this pins that Flow inputs are assembled from
	// the captured query snapshot, never enriched from GetRaw. It is NOT a
	// field-confidentiality guarantee: the engine snapshot read deliberately
	// returns every non-blocked field of the record under approval (#4397), and
	// what a notification exposes is decided by the Flow's authoring, exactly
	// as an approval template's merge fields are in Salesforce. Credential and
	// token-class fields stay unreadable through fieldReadBlocked, which is
	// unconditional and unaffected by this read principal.
	if got := record["Secret__c"]; got != nil {
		t.Fatalf("$Record.Secret__c = %#v, want absent: Flow inputs must come from the captured query snapshot, never from raw persistence (GetRaw)", got)
	}
	if got := models.RecordObjectType(record); got != "GenericObject" {
		t.Fatalf("$Record object type = %q, want GenericObject", got)
	}
	plainRecord, ok := call.inputs["record"].(models.SObject)
	if !ok {
		t.Fatalf("record input = %#v, want SObject", call.inputs["record"])
	}
	if !reflect.DeepEqual(plainRecord, record) {
		t.Fatalf("record input = %#v, want mirror of $Record %#v", plainRecord, record)
	}
	approval, ok := call.inputs["Approval"].(map[string]interface{})
	if !ok {
		t.Fatalf("Approval input = %#v, want map", call.inputs["Approval"])
	}
	assertApprovalBridgeValue(t, approval, "ActionEvent", "Submit")
	assertApprovalBridgeValue(t, approval, "ProcessDefinitionId", "pd-1")
	assertApprovalBridgeValue(t, approval, "ProcessInstanceId", "pi-1")
	assertApprovalBridgeValue(t, approval, "TargetObjectApiName", "GenericObject")
	assertApprovalBridgeValue(t, approval, "TargetRecordId", "target-1")
	assertApprovalBridgeValue(t, approval, "SubmitterUserId", "submitter-1")
	assertApprovalBridgeValue(t, approval, "ActorUserId", "submitter-1")
	assertApprovalBridgeValue(t, approval, "Comments", "please approve")
	assertApprovalBridgeStrings(t, approval, "WorkItemIds", []string{"wi-1", "wi-2"})
	assertApprovalBridgeStrings(t, approval, "ApproverUserIds", []string{"approver-1", "approver-2"})
}

func TestApprovalNotificationFlowVersionFailsClosedOnNonApprovalRuntimeView(t *testing.T) {
	view := flowFormulaRuntimeViewWithAddMonths(t)
	resolverCalled := false
	executor := &FlowExecutor{flowVersionFn: func(context.Context, string, *models.UserSession) (*models.FlowVersion, error) {
		resolverCalled = true
		return &models.FlowVersion{Id: "wrong-authority"}, nil
	}}

	_, err := executor.approvalNotificationFlowVersion(
		metadata.WithRuntimeMetadataView(t.Context(), view),
		"Approval_Notify",
		&models.UserSession{ID: "user-1"},
	)
	if err == nil {
		t.Fatal("approval notification lookup accepted a non-Approval Runtime view")
	}
	if resolverCalled {
		t.Fatal("approval notification lookup fell back after a wrong Runtime authority was bound")
	}
}

func TestApprovalNotificationFlowVersionRetainsLegacyResolverWithoutRuntimeView(t *testing.T) {
	want := &models.FlowVersion{Id: "legacy-notification-flow"}
	executor := &FlowExecutor{flowVersionFn: func(context.Context, string, *models.UserSession) (*models.FlowVersion, error) {
		return want, nil
	}}

	got, err := executor.approvalNotificationFlowVersion(
		t.Context(),
		"Approval_Notify",
		&models.UserSession{ID: "user-1"},
	)
	if err != nil {
		t.Fatal(err)
	}
	if got != want {
		t.Fatalf("legacy approval notification version = %p, want %p", got, want)
	}
}

func TestApprovalFlowNotificationContextShapesLifecyclePhases(t *testing.T) {
	tests := []struct {
		name        string
		event       string
		approverIDs []string
		workItemIDs []string
	}{
		{name: "submit", event: "Submit", approverIDs: []string{"approver-submit"}, workItemIDs: []string{"wi-submit"}},
		{name: "step action", event: "StepApproval", approverIDs: []string{"approver-step"}, workItemIDs: []string{"wi-step"}},
		{name: "final action", event: "FinalApproval", approverIDs: nil, workItemIDs: []string{"wi-final"}},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			batch := lifecycleActionBatch{
				objectApiName: "GenericObject",
				targetID:      "target-1",
				processDefID:  "pd-1",
				processNodeID: "pn-1",
				actionEvent:   tt.event,
			}
			got := batch.approvalContext([]string{"legacy-actor"}, approvalFlowNotificationContext{
				ProcessInstanceID: "pi-1",
				WorkItemIDs:       tt.workItemIDs,
				SubmitterUserID:   "submitter-1",
				ActorUserID:       "actor-1",
				ApproverUserIDs:   tt.approverIDs,
				Comments:          "comment",
			})

			if got.ActionEvent != tt.event {
				t.Fatalf("ActionEvent = %q, want %q", got.ActionEvent, tt.event)
			}
			if got.ProcessDefinitionID != "pd-1" || got.ProcessNodeID != "pn-1" || got.TargetObjectAPIName != "GenericObject" || got.TargetRecordID != "target-1" {
				t.Fatalf("batch-owned context fields = %#v", got)
			}
			if tt.approverIDs == nil {
				assertStringSliceEqual(t, got.ApproverUserIDs, []string{"legacy-actor"})
			} else {
				assertStringSliceEqual(t, got.ApproverUserIDs, tt.approverIDs)
			}
			assertStringSliceEqual(t, got.WorkItemIDs, tt.workItemIDs)
		})
	}
}

func TestApprovalService_ExecuteLifecycleActionBatchCapturesFlowTargetSnapshot(t *testing.T) {
	query := &approvalFlowBridgeQueryStub{t: t, records: map[string]models.SObject{
		"GenericObject/target-1": {
			constants.FieldID: "target-1",
			"Name":            "Snapshot from transaction",
		},
	}}
	svc := &ApprovalService{
		query:  query,
		logger: testApprovalLogger(),
	}

	// Stamp ambient standard-action scopes on the transaction ctx so the
	// query stub's Has* assertions actually discriminate the strip performed
	// by approvalNotificationSnapshotReadContext — deleting the strip lines
	// must fail this test, not pass vacuously.
	txCtx := constants.WithSecurityContext(context.Background(), constants.SystemContext, "approval transaction")
	txCtx = constants.WithStandardActionInternalAccessScopes(txCtx, "test-action", []constants.StandardActionInternalAccessScope{{
		StandardActionID: "test-action",
		ObjectAPIName:    "GenericObject",
		Operation:        "read",
	}})
	txCtx = constants.WithStandardActionProtectedFlowExecution(txCtx, "test-root-flow")
	notifications, err := svc.executeLifecycleActionBatch(
		txCtx,
		lifecycleActionBatch{
			actions: []models.SObject{{
				"Name":                               "Notify step",
				constants.FieldProcessAction_APIName: "Approval_Step_Notification",
				"ActionType":                         constants.ProcessActionTypeFlow,
			}},
			objectApiName: "GenericObject",
			targetID:      "target-1",
			processDefID:  "pd-1",
			actionEvent:   "StepApproval",
		},
		[]string{"approver-1"},
		approvalFlowNotificationContext{ProcessInstanceID: "pi-1"},
		&models.UserSession{ID: "approver-1"},
	)
	if err != nil {
		t.Fatalf("executeLifecycleActionBatch error = %v", err)
	}
	if notifications == nil {
		t.Fatal("executeLifecycleActionBatch returned nil notifications")
	}
	if len(query.specCalls) != 1 {
		t.Fatalf("target snapshot query calls = %d, want 1", len(query.specCalls))
	}
	if got := notifications.flowTargetSnapshot["Name"]; got != "Snapshot from transaction" {
		t.Fatalf("flow target snapshot Name = %#v, want transaction snapshot", got)
	}
	if got := models.RecordObjectType(notifications.flowTargetSnapshot); got != "GenericObject" {
		t.Fatalf("flow target snapshot object type = %q, want GenericObject", got)
	}
}

func TestApprovalService_SnapshotUnreadableFailsFlowNotificationClosed(t *testing.T) {
	// #4397 R1 ruling 3, end-to-end through capture: when the notification
	// target cannot be read (empty query result), the lifecycle batch carries
	// the capture error and the pending Flow notification fails closed as a
	// visible delivery failure instead of launching with an empty $Record.
	// Deleting the snapshot gate must fail this test (the executor would
	// record an execution call); reverting capture to fail-soft nil/nil must
	// fail it too (flowTargetSnapshotErr would be nil).
	query := &approvalFlowBridgeQueryStub{t: t, records: map[string]models.SObject{}}
	flow := &approvalFlowBridgeExecutorStub{metadata: models.FlowMetadata{
		ActionCalls: []models.FlowActionCall{{Name: "SendNotification", ActionType: flowSendEmailActionType}},
	}}
	svc := &ApprovalService{
		query:        query,
		flowExecutor: flow,
		logger:       testApprovalLogger(),
	}

	notifications, err := svc.executeLifecycleActionBatch(
		constants.WithSecurityContext(context.Background(), constants.SystemContext, "approval transaction"),
		lifecycleActionBatch{
			actions: []models.SObject{{
				"Name":                               "Notify final",
				constants.FieldProcessAction_APIName: "Approval_Final_Notification",
				"ActionType":                         constants.ProcessActionTypeFlow,
			}},
			objectApiName: "GenericObject",
			targetID:      "target-1",
			processDefID:  "pd-1",
			actionEvent:   "FinalApproval",
		},
		[]string{"approver-1"},
		approvalFlowNotificationContext{ProcessInstanceID: "pi-1"},
		&models.UserSession{ID: "approver-1"},
	)
	if err != nil {
		t.Fatalf("executeLifecycleActionBatch error = %v", err)
	}
	if notifications == nil {
		t.Fatal("executeLifecycleActionBatch returned nil notifications")
	}
	if notifications.flowTargetSnapshot != nil {
		t.Fatalf("flowTargetSnapshot = %#v, want nil for unreadable target", notifications.flowTargetSnapshot)
	}
	if notifications.flowTargetSnapshotErr == nil {
		t.Fatal("flowTargetSnapshotErr = nil, want capture error carried to delivery")
	}

	failures := svc.executeProcessActionNotifications(context.Background(), *notifications, &models.UserSession{ID: "approver-1"})
	if len(failures) != 1 {
		t.Fatalf("failures = %#v, want one closed Flow delivery failure", failures)
	}
	if failures[0].ActionType != constants.ProcessActionTypeFlow {
		t.Fatalf("failure ActionType = %q, want Flow", failures[0].ActionType)
	}
	if !strings.Contains(failures[0].Reason, "unreadable") {
		t.Fatalf("failure Reason = %q, want snapshot-unreadable reason", failures[0].Reason)
	}
	// NotFound is admin-actionable and must travel verbatim — not collapse to
	// the fixed internal-error string. Narrowing the classification (e.g.
	// dropping IsNotFound or turning || into &&) must fail here.
	if !strings.Contains(failures[0].Reason, "target-1") {
		t.Fatalf("failure Reason = %q, want verbatim NotFound detail naming target-1", failures[0].Reason)
	}
	if strings.Contains(failures[0].Reason, "see server logs") {
		t.Fatalf("failure Reason = %q, NotFound must not be collapsed to the see-server-logs string", failures[0].Reason)
	}
	if failures[0].TargetObject != "GenericObject" || failures[0].TargetID != "target-1" {
		t.Fatalf("failure target = %s/%s, want GenericObject/target-1", failures[0].TargetObject, failures[0].TargetID)
	}
	if len(flow.executionCalls) != 0 {
		t.Fatalf("Flow execution calls = %d, want 0 when the snapshot is unreadable", len(flow.executionCalls))
	}
}

func TestApprovalService_FlowNotificationWithoutSnapshotFailsClosed(t *testing.T) {
	// #4397 R1 ruling 3, gate unit: a Flow action whose pending payload
	// carries neither a snapshot nor a capture error (a constructor that
	// skipped capture entirely) still fails closed rather than fabricating an
	// empty $Record.
	flow := &approvalFlowBridgeExecutorStub{metadata: models.FlowMetadata{
		ActionCalls: []models.FlowActionCall{{Name: "SendNotification", ActionType: flowSendEmailActionType}},
	}}
	svc := &ApprovalService{
		flowExecutor: flow,
		logger:       testApprovalLogger(),
	}

	failures := svc.executeProcessActionNotifications(context.Background(), pendingNotifications{
		actions: []models.SObject{{
			"Name":                               "Notify final",
			constants.FieldProcessAction_APIName: "Approval_Final_Notification",
			"ActionType":                         constants.ProcessActionTypeFlow,
		}},
		objectApiName: "GenericObject",
		targetID:      "target-1",
		approval: approvalFlowNotificationContext{
			ActionEvent:         "FinalApproval",
			TargetObjectAPIName: "GenericObject",
			TargetRecordID:      "target-1",
		},
	}, &models.UserSession{ID: "actor-1"})

	if len(failures) != 1 {
		t.Fatalf("failures = %#v, want one closed Flow delivery failure", failures)
	}
	if !strings.Contains(failures[0].Reason, "not captured") {
		t.Fatalf("failure Reason = %q, want snapshot-not-captured reason", failures[0].Reason)
	}
	if len(flow.executionCalls) != 0 {
		t.Fatalf("Flow execution calls = %d, want 0 without a snapshot", len(flow.executionCalls))
	}
}

func TestApprovalService_SnapshotWarnReasonRendersVerbatim(t *testing.T) {
	// The delivery gate renders the stored warn verbatim: the capture
	// classifier is the single point that guarantees only user-safe
	// NotFound/Validation causes are ever stored (everything else aborts the
	// transaction — see the infra-failure-aborts subtests). Validation text
	// is admin-actionable and must not be collapsed.
	flow := &approvalFlowBridgeExecutorStub{metadata: models.FlowMetadata{
		ActionCalls: []models.FlowActionCall{{Name: "SendNotification", ActionType: flowSendEmailActionType}},
	}}
	svc := &ApprovalService{flowExecutor: flow, logger: testApprovalLogger()}

	failures := svc.executeProcessActionNotifications(context.Background(), pendingNotifications{
		actions: []models.SObject{{
			"Name":                               "Notify final",
			constants.FieldProcessAction_APIName: "Approval_Final_Notification",
			"ActionType":                         constants.ProcessActionTypeFlow,
		}},
		objectApiName:         "GenericObject",
		targetID:              "target-1",
		flowTargetSnapshotErr: apperrors.NewValidationError("TargetObjectApiName", "approval Flow notification requires TargetObjectApiName"),
		approval: approvalFlowNotificationContext{
			ActionEvent:         "FinalApproval",
			TargetObjectAPIName: "GenericObject",
			TargetRecordID:      "target-1",
		},
	}, &models.UserSession{ID: "actor-1"})

	if len(failures) != 1 {
		t.Fatalf("failures = %#v, want one closed Flow delivery failure", failures)
	}
	if !strings.Contains(failures[0].Reason, "TargetObjectApiName") {
		t.Fatalf("failure Reason = %q, want verbatim validation detail", failures[0].Reason)
	}
	if len(flow.executionCalls) != 0 {
		t.Fatalf("Flow execution calls = %d, want 0", len(flow.executionCalls))
	}
}

func TestApprovalService_AllPendingNotificationConstructorsThreadSnapshotAndError(t *testing.T) {
	// #4397 R1: every production constructor of pendingNotifications must
	// capture the snapshot and thread the capture error. Dropping the capture
	// call or the error from any single constructor (submit, lifecycle,
	// recall) must fail this test, not only the lifecycle-batch one.
	// Fixtures are built per subtest: batch action maps flow into production
	// code, and shared mutable fixtures across subtests made one full-suite
	// run in seven flake undiagnosably (recall subtests observed nil,nil).
	newBatch := func() lifecycleActionBatch {
		return lifecycleActionBatch{
			actions: []models.SObject{{
				"Name":                               "Notify",
				constants.FieldProcessAction_APIName: "Approval_Notification",
				"ActionType":                         constants.ProcessActionTypeFlow,
			}},
			objectApiName: "GenericObject",
			targetID:      "target-1",
			processDefID:  "pd-1",
			actionEvent:   "StepApproval",
		}
	}
	newCtx := func() context.Context {
		return constants.WithSecurityContext(context.Background(), constants.SystemContext, "approval transaction")
	}

	constructors := []struct {
		name      string
		construct func(svc *ApprovalService) (*pendingNotifications, error)
	}{
		{name: "executeLifecycleActionBatch", construct: func(svc *ApprovalService) (*pendingNotifications, error) {
			return svc.executeLifecycleActionBatch(newCtx(), newBatch(), []string{"approver-1"}, approvalFlowNotificationContext{ProcessInstanceID: "pi-1"}, &models.UserSession{ID: "approver-1"})
		}},
		{name: "pendingNotificationsForLifecycleBatch", construct: func(svc *ApprovalService) (*pendingNotifications, error) {
			pending, err := svc.pendingNotificationsForLifecycleBatch(newCtx(), newBatch(), []string{"approver-1"}, approvalFlowNotificationContext{ProcessInstanceID: "pi-1"}, &models.UserSession{ID: "approver-1"})
			if err != nil {
				return nil, err
			}
			return &pending, nil
		}},
		{name: "executeRecallLifecycleActionBatch", construct: func(svc *ApprovalService) (*pendingNotifications, error) {
			return svc.executeRecallLifecycleActionBatch(newCtx(), newBatch(), []string{"approver-1"}, approvalFlowNotificationContext{ProcessInstanceID: "pi-1"}, &models.UserSession{ID: "approver-1"})
		}},
	}

	for _, tc := range constructors {
		t.Run(tc.name+"/captured", func(t *testing.T) {
			query := &approvalFlowBridgeQueryStub{t: t, records: map[string]models.SObject{
				"GenericObject/target-1": {constants.FieldID: "target-1", "Name": "Readable target"},
			}}
			svc := &ApprovalService{query: query, logger: testApprovalLogger()}
			pending, err := tc.construct(svc)
			if err != nil {
				t.Fatalf("%s error = %v", tc.name, err)
			}
			if pending == nil {
				t.Fatalf("%s returned nil pendingNotifications", tc.name)
			}
			if pending.flowTargetSnapshot == nil || pending.flowTargetSnapshot["Name"] != "Readable target" {
				t.Fatalf("%s flowTargetSnapshot = %#v, want captured record", tc.name, pending.flowTargetSnapshot)
			}
			if pending.flowTargetSnapshotErr != nil {
				t.Fatalf("%s flowTargetSnapshotErr = %v, want nil on readable target", tc.name, pending.flowTargetSnapshotErr)
			}
			if len(query.specCalls) != 1 {
				t.Fatalf("%s snapshot query calls = %d, want 1", tc.name, len(query.specCalls))
			}
		})
		t.Run(tc.name+"/not-found-is-warn-lane", func(t *testing.T) {
			// A missing target row (NotFound) is a user-safe non-fatal cause:
			// per the v63 contract it may surface as a successful-response
			// actionFailure, so the transaction proceeds with the warn stored.
			query := &approvalFlowBridgeQueryStub{t: t, records: map[string]models.SObject{}}
			svc := &ApprovalService{query: query, logger: testApprovalLogger()}
			pending, err := tc.construct(svc)
			if err != nil {
				t.Fatalf("%s error = %v, want nil (NotFound must not abort the transaction)", tc.name, err)
			}
			if pending == nil {
				t.Fatalf("%s returned nil pendingNotifications", tc.name)
			}
			if pending.flowTargetSnapshot != nil {
				t.Fatalf("%s flowTargetSnapshot = %#v, want nil for unreadable target", tc.name, pending.flowTargetSnapshot)
			}
			if pending.flowTargetSnapshotErr == nil {
				t.Fatalf("%s flowTargetSnapshotErr = nil, want capture warn carried to delivery", tc.name)
			}
		})
		t.Run(tc.name+"/infra-failure-aborts", func(t *testing.T) {
			// v63 contract (docs/design/salesforce-v63-api-contract.md,
			// approvals actionFailures carve-out): infrastructure /
			// cancellation / internal failures must NOT be converted into
			// successful-response actionFailures — the capture returns an
			// error and the enclosing approval transaction aborts. Storing
			// such an error in flowTargetSnapshotErr instead must fail here.
			query := &approvalFlowBridgeQueryStub{t: t, specErr: apperrors.NewInternalError("query execution error", fmt.Errorf("Error 9005: region unavailable"))}
			svc := &ApprovalService{query: query, logger: testApprovalLogger()}
			pending, err := tc.construct(svc)
			if err == nil {
				t.Fatalf("%s error = nil, want infra capture failure to abort (pending=%#v)", tc.name, pending)
			}
			if !apperrors.IsInternal(err) {
				t.Fatalf("%s error = %v, want internal classification preserved", tc.name, err)
			}
		})
	}
}

func TestApprovalService_ProcessActionFlowFailureSurfacesActionFailure(t *testing.T) {
	flow := &approvalFlowBridgeExecutorStub{metadata: models.FlowMetadata{
		ActionCalls: []models.FlowActionCall{{Name: "SendNotification", ActionType: flowSendEmailActionType}},
	}, result: &models.FlowExecutionResult{
		ActionName: "Approval_Flow",
		IsSuccess:  false,
		Errors: []models.FlowExecutionError{{
			StatusCode: "FLOW_EXECUTION_ERROR",
			Message:    "template variable missing",
		}},
	}}
	svc := &ApprovalService{
		persistence: &approvalFlowBridgePersistenceStub{records: map[string]models.SObject{
			"GenericObject/target-1": {constants.FieldID: "target-1"},
		}},
		query: &approvalFlowBridgeQueryStub{t: t, records: map[string]models.SObject{
			"GenericObject/target-1": {constants.FieldID: "target-1"},
		}},
		flowExecutor: flow,
		logger:       testApprovalLogger(),
	}

	failures := svc.executeProcessActionNotifications(context.Background(), pendingNotifications{
		actions: []models.SObject{{
			"Name":                               "Notify final",
			constants.FieldProcessAction_APIName: "Approval_Flow",
			"ActionType":                         constants.ProcessActionTypeFlow,
		}},
		objectApiName:      "GenericObject",
		targetID:           "target-1",
		flowTargetSnapshot: models.SObject{constants.FieldID: "target-1"},
		approval: approvalFlowNotificationContext{
			ActionEvent:         "FinalApproval",
			ProcessDefinitionID: "pd-1",
			ProcessInstanceID:   "pi-1",
			TargetObjectAPIName: "GenericObject",
			TargetRecordID:      "target-1",
		},
	}, &models.UserSession{ID: "actor-1"})

	if len(failures) != 1 {
		t.Fatalf("failures = %#v, want one Flow failure", failures)
	}
	if failures[0].ActionType != constants.ProcessActionTypeFlow {
		t.Fatalf("failure ActionType = %q, want Flow", failures[0].ActionType)
	}
	if !strings.Contains(failures[0].Reason, "FLOW_EXECUTION_ERROR: template variable missing") {
		t.Fatalf("failure Reason = %q, want Flow error details", failures[0].Reason)
	}
}

func TestApprovalService_ProcessActionFlowRejectsUnsafeFlowBeforeTargetRead(t *testing.T) {
	persistence := &approvalFlowBridgePersistenceStub{records: map[string]models.SObject{
		"GenericObject/target-1": {constants.FieldID: "target-1"},
	}}
	query := &approvalFlowBridgeQueryStub{t: t, records: map[string]models.SObject{
		"GenericObject/target-1": {constants.FieldID: "target-1"},
	}}
	flow := &approvalFlowBridgeExecutorStub{metadata: models.FlowMetadata{
		RecordUpdates: []models.FlowRecordUpdate{{Name: "MutateTarget", Object: "GenericObject"}},
	}}
	svc := &ApprovalService{
		persistence:  persistence,
		query:        query,
		flowExecutor: flow,
		logger:       testApprovalLogger(),
	}

	failures := svc.executeProcessActionNotifications(constants.WithSecurityContext(context.Background(), constants.SystemContext, "approval transaction"), pendingNotifications{
		actions: []models.SObject{{
			"Name":                               "Notify final",
			constants.FieldProcessAction_APIName: "Approval_Flow",
			"ActionType":                         constants.ProcessActionTypeFlow,
		}},
		objectApiName: "GenericObject",
		targetID:      "target-1",
		approval: approvalFlowNotificationContext{
			ActionEvent:         "FinalApproval",
			ProcessDefinitionID: "pd-1",
			ProcessInstanceID:   "pi-1",
			TargetObjectAPIName: "GenericObject",
			TargetRecordID:      "target-1",
		},
	}, &models.UserSession{ID: "actor-1"})

	if len(failures) != 1 {
		t.Fatalf("failures = %#v, want one unsafe Flow failure", failures)
	}
	if !strings.Contains(failures[0].Reason, "not notification-safe") {
		t.Fatalf("failure Reason = %q, want notification-safe rejection", failures[0].Reason)
	}
	if len(flow.executionCalls) != 0 {
		t.Fatalf("Flow execution calls = %d, want 0 for unsafe metadata", len(flow.executionCalls))
	}
	if len(query.specCalls) != 0 {
		t.Fatalf("target query calls = %d, want 0 before unsafe metadata is rejected", len(query.specCalls))
	}
	if persistence.getRawCalls != 0 {
		t.Fatalf("GetRaw calls = %d, want 0 for unsafe approval Flow notifications", persistence.getRawCalls)
	}
}

func TestValidateApprovalNotificationFlowMetadataRejectsUnsafeElements(t *testing.T) {
	tests := []struct {
		name string
		meta models.FlowMetadata
	}{
		{name: "record create", meta: models.FlowMetadata{RecordCreates: []models.FlowRecordCreate{{Name: "CreateTask"}}}},
		{name: "record update", meta: models.FlowMetadata{RecordUpdates: []models.FlowRecordUpdate{{Name: "UpdateRecord"}}}},
		{name: "record delete", meta: models.FlowMetadata{RecordDeletes: []models.FlowRecordDelete{{Name: "DeleteRecord"}}}},
		{name: "record lookup", meta: models.FlowMetadata{RecordLookups: []models.FlowRecordLookup{{Name: "LookupTarget", Object: "GenericObject"}}}},
		{name: "subflow", meta: models.FlowMetadata{Subflows: []models.FlowSubflow{{Name: "CallChild"}}}},
		{name: "screen", meta: models.FlowMetadata{Screens: []models.FlowScreen{{Name: "Screen1"}}}},
		{name: "wait", meta: models.FlowMetadata{Waits: []models.FlowWait{{Name: "Pause"}}}},
		{name: "custom error", meta: models.FlowMetadata{CustomErrors: []models.FlowCustomError{{Name: "BlockApproval"}}}},
		{name: "email alert", meta: models.FlowMetadata{ActionCalls: []models.FlowActionCall{{Name: "LegacyAlert", ActionType: "emailAlert"}}}},
		{name: "webhook", meta: models.FlowMetadata{ActionCalls: []models.FlowActionCall{{Name: "Webhook", ActionType: constants.ActionTypePushToConnector}}}},
		{name: "nested flow action", meta: models.FlowMetadata{ActionCalls: []models.FlowActionCall{{Name: "ChildFlow", ActionType: "flow"}}}},
		{name: "record trigger start", meta: models.FlowMetadata{Start: &models.FlowStart{Object: "GenericObject", TriggerType: "RecordAfterSave"}}},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := ValidateApprovalNotificationFlowMetadata("Approval_Flow", &tt.meta)
			if err == nil {
				t.Fatal("ValidateApprovalNotificationFlowMetadata returned nil, want unsafe Flow error")
			}
			if !strings.Contains(err.Error(), "not notification-safe") {
				t.Fatalf("error = %q, want notification-safe rejection", err.Error())
			}
		})
	}
}

func TestValidateApprovalNotificationFlowMetadataUsesRegisteredEmailActionCapability(t *testing.T) {
	restore := ResetDefaultAppFlowActionCallRegistrationsForTest()
	t.Cleanup(restore)

	meta := &models.FlowMetadata{ActionCalls: []models.FlowActionCall{{
		Name:       "SendLegacyEmailAlias",
		ActionType: "emailSimple",
	}}}

	err := ValidateApprovalNotificationFlowMetadata("Approval_Flow", meta)
	if err == nil {
		t.Fatal("ValidateApprovalNotificationFlowMetadata(emailSimple) error = nil before app registration")
	}
	if !strings.Contains(err.Error(), `actionType "emailSimple" is outside notification scope`) {
		t.Fatalf("unregistered emailSimple error = %q, want notification-scope rejection", err.Error())
	}

	RegisterDefaultAppFlowActionCallRegistrations(
		LegacySendEmailAliasFlowActionCallRegistration("emailSimple", "crm:automation/email-simple"),
	)

	if err := ValidateApprovalNotificationFlowMetadata("Approval_Flow", meta); err != nil {
		t.Fatalf("ValidateApprovalNotificationFlowMetadata(emailSimple) error = %v, want nil after app registration", err)
	}
}

func TestValidateApprovalNotificationFlowMetadataUsesPackBackedActionRegistration(t *testing.T) {
	restore := ResetDefaultAppFlowActionCallRegistrationsForTest()
	t.Cleanup(restore)

	meta := &models.FlowMetadata{ActionCalls: []models.FlowActionCall{{
		Name:       "SendLegacyAlias",
		ActionType: "emailSimple",
	}}}

	registrations, err := FlowActionCallRegistrationsFromDescriptors(
		[]FlowActionCallRegistrationDescriptor{{
			ActionType:      "emailSimple",
			HandlerRef:      "crm:automation/email-simple",
			SendsEmail:      true,
			AtomicPolicy:    "reject",
			PreviewPolicy:   "reject_email_io",
			RequiresContext: "none",
		}},
		LegacySendEmailAliasFlowActionCallHandlerImplementation("crm:automation/email-simple"),
	)
	if err != nil {
		t.Fatalf("FlowActionCallRegistrationsFromDescriptors: %v", err)
	}
	RegisterDefaultAppFlowActionCallRegistrations(registrations...)

	if err := ValidateApprovalNotificationFlowMetadata("Approval_Flow", meta); err != nil {
		t.Fatalf("ValidateApprovalNotificationFlowMetadata(emailSimple) error = %v, want nil after pack-backed registration", err)
	}
}

// TestValidateApprovalNotificationFlowMetadataAcceptsSendNotification proves
// the forcing use case for NX#930 — notify a stamped third-party record
// owner on final approval — is admitted into the approval-notification
// sandbox WITHOUT widening it: sendNotification is platform-registered with
// ApprovalNotificationSafe:true, so no DefaultApp registration is required
// (unlike the CRM-owned emailSimple alias above), and RecordCreate/RecordLookup
// stay rejected in the same flow.
func TestValidateApprovalNotificationFlowMetadataAcceptsSendNotification(t *testing.T) {
	for _, actionType := range []string{"sendNotification", "SendNotification", "SENDNOTIFICATION"} {
		t.Run(actionType, func(t *testing.T) {
			meta := &models.FlowMetadata{ActionCalls: []models.FlowActionCall{{
				Name:       "Notify_Owner",
				ActionType: actionType,
			}}}
			if err := ValidateApprovalNotificationFlowMetadata("Approval_Flow", meta); err != nil {
				t.Fatalf("ValidateApprovalNotificationFlowMetadata(%s) error = %v, want nil (platform-registered, no app registration needed)", actionType, err)
			}
		})
	}
}

func TestValidateApprovalNotificationFlowMetadataRejectsUnregisteredActionTypeEvenWithSendNotificationPresent(t *testing.T) {
	meta := &models.FlowMetadata{ActionCalls: []models.FlowActionCall{
		{Name: "Notify_Owner", ActionType: "sendNotification"},
		{Name: "SomethingElse", ActionType: "notAnActionType"},
	}}
	err := ValidateApprovalNotificationFlowMetadata("Approval_Flow", meta)
	if err == nil {
		t.Fatal("ValidateApprovalNotificationFlowMetadata() error = nil, want rejection for the unregistered actionType")
	}
	if !strings.Contains(err.Error(), `actionType "notAnActionType" is outside notification scope`) {
		t.Fatalf("error = %q, want notification-scope rejection naming notAnActionType", err.Error())
	}
}

func TestValidateApprovalNotificationFlowMetadataStillRejectsRecordCreateAlongsideSendNotification(t *testing.T) {
	meta := &models.FlowMetadata{
		ActionCalls:   []models.FlowActionCall{{Name: "Notify_Owner", ActionType: "sendNotification"}},
		RecordCreates: []models.FlowRecordCreate{{Name: "CreateAudit"}},
	}
	err := ValidateApprovalNotificationFlowMetadata("Approval_Flow", meta)
	if err == nil {
		t.Fatal("ValidateApprovalNotificationFlowMetadata() error = nil, want RecordCreate rejection even with sendNotification present")
	}
	if !strings.Contains(err.Error(), "not notification-safe") {
		t.Fatalf("error = %q, want notification-safe rejection", err.Error())
	}
}

func assertApprovalBridgeValue(t *testing.T, approval map[string]interface{}, key string, want string) {
	t.Helper()
	if got, _ := approval[key].(string); got != want {
		t.Fatalf("Approval.%s = %#v, want %q", key, approval[key], want)
	}
}

func assertApprovalBridgeStrings(t *testing.T, approval map[string]interface{}, key string, want []string) {
	t.Helper()
	got, ok := approval[key].([]string)
	if !ok {
		t.Fatalf("Approval.%s = %#v, want []string", key, approval[key])
	}
	assertStringSliceEqual(t, got, want)
}

func assertStringSliceEqual(t *testing.T, got []string, want []string) {
	t.Helper()
	if len(got) != len(want) {
		t.Fatalf("slice len = %d (%#v), want %d (%#v)", len(got), got, len(want), want)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Fatalf("slice[%d] = %q, want %q (full=%#v)", i, got[i], want[i], got)
		}
	}
}

func testApprovalLogger() *logging.Logger {
	return logging.Default
}
