package automation

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

	"github.com/nexuscrm/events"
	"github.com/nexuscrm/kernel/pkg/logging"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

// sendNotificationInsertCall captures one Insert call, including the full
// ctx, so tests can assert the SystemContext-elevation scoping contract and
// the record-triggered-automation suppression tuple without leaking the
// elevated ctx to callers.
type sendNotificationInsertCall struct {
	tableName       string
	record          models.SObject
	securityContext constants.SecurityContext
	ctx             context.Context
}

// fakeSendNotificationPersistence is a minimal PersistenceProvider double
// scoped to executor_notifications_test.go. Only GetRaw and Insert are
// exercised by sendNotification; every other method panics so an
// accidental dependency on unimplemented behavior fails loudly.
type fakeSendNotificationPersistence struct {
	users map[string]models.SObject

	getRawErr   error
	getRawCalls []string

	insertErr   error
	insertCalls []sendNotificationInsertCall
}

func (f *fakeSendNotificationPersistence) GetRaw(ctx context.Context, objectName string, id string) (models.SObject, error) {
	f.getRawCalls = append(f.getRawCalls, objectName+"/"+id)
	if f.getRawErr != nil {
		return nil, f.getRawErr
	}
	if row, ok := f.users[id]; ok {
		return row, nil
	}
	// GetRaw's not-found contract: (nil, nil), never a NotFoundError.
	return nil, nil
}

func (f *fakeSendNotificationPersistence) Insert(ctx context.Context, tableName string, record models.SObject, _ *models.UserSession) (models.SObject, error) {
	snap := make(models.SObject, len(record))
	for k, v := range record {
		snap[k] = v
	}
	f.insertCalls = append(f.insertCalls, sendNotificationInsertCall{
		tableName:       tableName,
		record:          snap,
		securityContext: constants.GetSecurityContext(ctx),
		ctx:             ctx,
	})
	if f.insertErr != nil {
		return nil, f.insertErr
	}
	return snap, nil
}

func (f *fakeSendNotificationPersistence) PreviewBeforeInsert(context.Context, string, models.SObject, *models.UserSession) (models.SObject, error) {
	panic("PreviewBeforeInsert not used by sendNotification")
}
func (f *fakeSendNotificationPersistence) PrelockUpdateParents(context.Context, string, string, models.SObject, *models.UserSession) error {
	panic("PrelockUpdateParents not used by sendNotification")
}
func (f *fakeSendNotificationPersistence) Update(context.Context, string, string, models.SObject, *models.UserSession) error {
	panic("Update not used by sendNotification")
}
func (f *fakeSendNotificationPersistence) PreviewBeforeUpdate(context.Context, string, string, models.SObject, models.SObject, *models.UserSession) (*models.PreparedRecordUpdate, error) {
	panic("PreviewBeforeUpdate not used by sendNotification")
}
func (f *fakeSendNotificationPersistence) CommitPreparedUpdate(context.Context, string, string, *models.PreparedRecordUpdate, models.SObject, *models.UserSession) error {
	panic("CommitPreparedUpdate not used by sendNotification")
}
func (f *fakeSendNotificationPersistence) Delete(context.Context, string, string, *models.UserSession) error {
	panic("Delete not used by sendNotification")
}
func (f *fakeSendNotificationPersistence) GetLock(context.Context, string, string) (models.SObject, error) {
	panic("GetLock not used by sendNotification")
}
func (f *fakeSendNotificationPersistence) GetRecordType(context.Context, string) (string, error) {
	panic("GetRecordType not used by sendNotification")
}
func (f *fakeSendNotificationPersistence) RunInTransaction(ctx context.Context, fn func(context.Context) error) error {
	return fn(ctx)
}
func (f *fakeSendNotificationPersistence) IsTransactionActive(context.Context) bool { return false }
func (f *fakeSendNotificationPersistence) RunInPessimisticTransaction(ctx context.Context, fn func(context.Context) error) error {
	return fn(ctx)
}
func (f *fakeSendNotificationPersistence) DetachTx(ctx context.Context) context.Context { return ctx }
func (f *fakeSendNotificationPersistence) RecomputeActiveFormulaProjection(context.Context, *models.UserSession, string, string, []string) (models.SObject, error) {
	return nil, nil
}

func sendNotificationTestExecutor(persistence PersistenceProvider) *FlowExecutor {
	return newFlowExecutorWithPlatformActionCalls(nil, nil, nil, persistence, nil, nil, logging.New(context.Background()))
}

func sendNotificationTestAction(params ...models.FlowActionParameter) *models.FlowActionCall {
	return &models.FlowActionCall{
		Name:            "Notify_Owner",
		ActionName:      "Notify_Owner",
		ActionType:      flowSendNotificationActionType,
		InputParameters: append([]models.FlowActionParameter{}, params...),
		Connector:       &models.FlowConnector{TargetReference: "Next"},
	}
}

func stringLiteralParam(name, value string) models.FlowActionParameter {
	return models.FlowActionParameter{Name: name, Value: map[string]interface{}{"stringValue": value}}
}

// --- Happy path -------------------------------------------------------

func TestSendNotificationActionHappyPathElementReferenceRecipient(t *testing.T) {
	persistence := &fakeSendNotificationPersistence{
		users: map[string]models.SObject{
			"user-active-1": {constants.FieldID: "user-active-1", constants.FieldUser_IsActive: true},
		},
	}
	executor := sendNotificationTestExecutor(persistence)
	interview := &models.FlowInterview{
		Id: "interview-1",
		ContextData: map[string]interface{}{
			"$Record": models.SObject{"OwnerId": "user-active-1"},
		},
	}
	action := sendNotificationTestAction(
		models.FlowActionParameter{Name: "recipientUserId", Value: map[string]interface{}{"elementReference": "$Record.OwnerId"}},
		stringLiteralParam("title", "A record needs your attention."),
		stringLiteralParam("body", "Please review MyObject__c/rec-1."),
		stringLiteralParam("link", "/object/MyObject__c/rec-1"),
		stringLiteralParam("notificationType", "approval"),
	)

	next, err := executor.executeActionCall(context.Background(), interview, &models.FlowMetadata{}, action, &models.UserSession{ID: "actor-1"})
	if err != nil {
		t.Fatalf("executeActionCall() error = %v", err)
	}
	if next != "Next" {
		t.Fatalf("next = %q, want Next", next)
	}
	if len(persistence.insertCalls) != 1 {
		t.Fatalf("Insert calls = %d, want 1", len(persistence.insertCalls))
	}
	call := persistence.insertCalls[0]
	if call.tableName != constants.TableNotification {
		t.Fatalf("Insert tableName = %q, want %q", call.tableName, constants.TableNotification)
	}
	if got := call.record[constants.FieldNotification_RecipientID]; got != "user-active-1" {
		t.Fatalf("recipient_id = %#v, want user-active-1", got)
	}
	if got := call.record[constants.FieldNotification_Title]; got != "A record needs your attention." {
		t.Fatalf("title = %#v", got)
	}
	if got := call.record[constants.FieldNotification_Body]; got != "Please review MyObject__c/rec-1." {
		t.Fatalf("body = %#v", got)
	}
	if got := call.record[constants.FieldNotification_Link]; got != "/object/MyObject__c/rec-1" {
		t.Fatalf("link = %#v", got)
	}
	if got := call.record[constants.FieldNotification_NotificationType]; got != "approval" {
		t.Fatalf("notification_type = %#v, want approval", got)
	}
	if got := call.record[constants.FieldNotification_IsRead]; got != false {
		t.Fatalf("is_read = %#v, want false", got)
	}
}

// --- Pure builder cases -------------------------------------------------

func TestBuildSendNotificationRequestDefaultsUnknownParamsAndReferences(t *testing.T) {
	executor := sendNotificationTestExecutor(&fakeSendNotificationPersistence{})
	interview := &models.FlowInterview{ContextData: map[string]interface{}{
		"reviewerVar": "user-var-1",
	}}
	action := &models.FlowActionCall{
		Name: "Notify",
		InputParameters: []models.FlowActionParameter{
			{Name: "recipientUserId", Value: map[string]interface{}{"elementReference": "reviewerVar"}},
			stringLiteralParam("title", "Hello"),
			{Name: "unknownParam", Value: map[string]interface{}{"stringValue": "ignored"}},
		},
	}

	req, err := executor.buildSendNotificationRequest(context.Background(), interview, action, &models.UserSession{ID: "u1"})
	if err != nil {
		t.Fatalf("buildSendNotificationRequest() error = %v", err)
	}
	if req.RecipientUserID != "user-var-1" {
		t.Fatalf("RecipientUserID = %q, want user-var-1", req.RecipientUserID)
	}
	if req.Title != "Hello" {
		t.Fatalf("Title = %q, want Hello", req.Title)
	}
	if req.NotificationType != flowSendNotificationDefaultType {
		t.Fatalf("NotificationType = %q, want default %q", req.NotificationType, flowSendNotificationDefaultType)
	}
}

func TestBuildSendNotificationRequestTruncatesLongTitle(t *testing.T) {
	executor := sendNotificationTestExecutor(&fakeSendNotificationPersistence{})
	longTitle := strings.Repeat("a", 300)
	action := &models.FlowActionCall{
		Name: "Notify",
		InputParameters: []models.FlowActionParameter{
			stringLiteralParam("recipientUserId", "user-1"),
			stringLiteralParam("title", longTitle),
		},
	}

	req, err := executor.buildSendNotificationRequest(context.Background(), &models.FlowInterview{ContextData: map[string]interface{}{}}, action, &models.UserSession{ID: "u1"})
	if err != nil {
		t.Fatalf("buildSendNotificationRequest() error = %v", err)
	}
	if got := len([]rune(req.Title)); got != flowSendNotificationTitleMaxRunes {
		t.Fatalf("len(Title) = %d, want %d", got, flowSendNotificationTitleMaxRunes)
	}
}

// --- Tier 1: authoring errors fault -------------------------------------

func TestSendNotificationActionFaultsOnMissingTitle(t *testing.T) {
	persistence := &fakeSendNotificationPersistence{}
	executor := sendNotificationTestExecutor(persistence)
	action := sendNotificationTestAction(stringLiteralParam("recipientUserId", "user-1"))

	next, err := executor.executeActionCall(context.Background(), &models.FlowInterview{ContextData: map[string]interface{}{}}, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
	if err == nil {
		t.Fatal("executeActionCall() error = nil, want title-required validation error (no FaultConnector wired)")
	}
	if !strings.Contains(err.Error(), "title is required") {
		t.Fatalf("error = %v, want title required message", err)
	}
	if next != "" {
		t.Fatalf("next = %q, want empty", next)
	}
	if len(persistence.insertCalls) != 0 {
		t.Fatalf("Insert calls = %d, want 0", len(persistence.insertCalls))
	}
}

func TestSendNotificationActionFaultsOnNonStringTitle(t *testing.T) {
	persistence := &fakeSendNotificationPersistence{}
	executor := sendNotificationTestExecutor(persistence)
	action := sendNotificationTestAction(
		stringLiteralParam("recipientUserId", "user-1"),
		models.FlowActionParameter{Name: "title", Value: map[string]interface{}{"numberValue": float64(42)}},
	)

	_, err := executor.executeActionCall(context.Background(), &models.FlowInterview{ContextData: map[string]interface{}{}}, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
	if err == nil {
		t.Fatal("executeActionCall() error = nil, want non-string title validation error")
	}
	if !strings.Contains(err.Error(), "title must be a string") {
		t.Fatalf("error = %v, want title-must-be-a-string message", err)
	}
	if len(persistence.insertCalls) != 0 {
		t.Fatalf("Insert calls = %d, want 0", len(persistence.insertCalls))
	}
}

// --- Tier 2: fail-safe skips ---------------------------------------------

// TestSendNotificationActionSkipsWhenRecipientOmitted covers the
// authoring-time case: recipientUserId simply not wired at all.
func TestSendNotificationActionSkipsWhenRecipientOmitted(t *testing.T) {
	persistence := &fakeSendNotificationPersistence{}
	executor := sendNotificationTestExecutor(persistence)
	action := sendNotificationTestAction(stringLiteralParam("title", "Hello"))
	action.FaultConnector = &models.FlowConnector{TargetReference: "OnFault"}
	interview := &models.FlowInterview{ContextData: map[string]interface{}{}}

	next, err := executor.executeActionCall(context.Background(), interview, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
	if err != nil {
		t.Fatalf("executeActionCall() error = %v, want nil (fail-safe skip must not propagate)", err)
	}
	if next != "Next" {
		t.Fatalf("next = %q, want Next (normal connector; Tier 2 must not consume a wired FaultConnector)", next)
	}
	if len(persistence.insertCalls) != 0 {
		t.Fatalf("Insert calls = %d, want 0", len(persistence.insertCalls))
	}
	if len(persistence.getRawCalls) != 0 {
		t.Fatalf("GetRaw calls = %d, want 0 (blank recipient skips before the lookup)", len(persistence.getRawCalls))
	}
}

// TestSendNotificationActionSkipsWhenRecipientReferenceResolvesEmpty covers
// the runtime case the finding is about: recipientUserId is wired via
// elementReference (the design's supported addressing path for the
// approval-notification sandbox, e.g. $Record.OwnerId) but the referenced
// field is blank at runtime. This must fail-safe skip like any other invalid
// recipient, not fault the flow / surface a ProcessActionFailure.
func TestSendNotificationActionSkipsWhenRecipientReferenceResolvesEmpty(t *testing.T) {
	persistence := &fakeSendNotificationPersistence{}
	executor := sendNotificationTestExecutor(persistence)
	interview := &models.FlowInterview{
		Id: "interview-1",
		ContextData: map[string]interface{}{
			"$Record": models.SObject{"OwnerId": ""},
		},
	}
	action := sendNotificationTestAction(
		models.FlowActionParameter{Name: "recipientUserId", Value: map[string]interface{}{"elementReference": "$Record.OwnerId"}},
		stringLiteralParam("title", "Hello"),
	)
	action.FaultConnector = &models.FlowConnector{TargetReference: "OnFault"}

	next, err := executor.executeActionCall(context.Background(), interview, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
	if err != nil {
		t.Fatalf("executeActionCall() error = %v, want nil (fail-safe skip must not propagate)", err)
	}
	if next != "Next" {
		t.Fatalf("next = %q, want Next (normal connector; an empty-resolved reference must not be treated as an authoring fault)", next)
	}
	if len(persistence.insertCalls) != 0 {
		t.Fatalf("Insert calls = %d, want 0", len(persistence.insertCalls))
	}
	if len(persistence.getRawCalls) != 0 {
		t.Fatalf("GetRaw calls = %d, want 0 (blank recipient skips before the lookup)", len(persistence.getRawCalls))
	}
}

// TestSendNotificationActionSkipsWhenTitleReferenceResolvesEmpty: a DECLARED
// title wired via elementReference that resolves blank at runtime is Tier-2
// (skip + audit), symmetric with the blank-recipient case — it must not
// surface a ProcessActionFailure in the approval-notification sandbox. Only
// an entirely undeclared title param is a Tier-1 authoring fault
// (TestSendNotificationActionFaultsOnMissingTitle above).
func TestSendNotificationActionSkipsWhenTitleReferenceResolvesEmpty(t *testing.T) {
	persistence := &fakeSendNotificationPersistence{
		users: map[string]models.SObject{
			"user-active-1": {constants.FieldID: "user-active-1", constants.FieldUser_IsActive: true},
		},
	}
	executor := sendNotificationTestExecutor(persistence)
	interview := &models.FlowInterview{
		Id: "interview-1",
		ContextData: map[string]interface{}{
			"$Record": models.SObject{"Subject__c": ""},
		},
	}
	action := sendNotificationTestAction(
		stringLiteralParam("recipientUserId", "user-active-1"),
		models.FlowActionParameter{Name: "title", Value: map[string]interface{}{"elementReference": "$Record.Subject__c"}},
	)
	action.FaultConnector = &models.FlowConnector{TargetReference: "OnFault"}

	next, err := executor.executeActionCall(context.Background(), interview, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
	if err != nil {
		t.Fatalf("executeActionCall() error = %v, want nil (fail-safe skip must not propagate)", err)
	}
	if next != "Next" {
		t.Fatalf("next = %q, want Next (normal connector; an empty-resolved title must not be treated as an authoring fault)", next)
	}
	if len(persistence.insertCalls) != 0 {
		t.Fatalf("Insert calls = %d, want 0", len(persistence.insertCalls))
	}
	if len(persistence.getRawCalls) != 0 {
		t.Fatalf("GetRaw calls = %d, want 0 (blank title skips before the recipient lookup)", len(persistence.getRawCalls))
	}
}

// Whitespace-only literals trim to blank in sendNotificationStringParam, so a
// declared all-whitespace title takes the same Tier-2 skip path as an
// empty-resolved reference.
func TestSendNotificationActionSkipsWhenTitleWhitespaceOnly(t *testing.T) {
	persistence := &fakeSendNotificationPersistence{
		users: map[string]models.SObject{
			"user-active-1": {constants.FieldID: "user-active-1", constants.FieldUser_IsActive: true},
		},
	}
	executor := sendNotificationTestExecutor(persistence)
	action := sendNotificationTestAction(
		stringLiteralParam("recipientUserId", "user-active-1"),
		stringLiteralParam("title", "   "),
	)
	action.FaultConnector = &models.FlowConnector{TargetReference: "OnFault"}
	interview := &models.FlowInterview{ContextData: map[string]interface{}{}}

	next, err := executor.executeActionCall(context.Background(), interview, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
	if err != nil {
		t.Fatalf("executeActionCall() error = %v, want nil (fail-safe skip must not propagate)", err)
	}
	if next != "Next" {
		t.Fatalf("next = %q, want Next (normal connector; Tier 2 must not consume a wired FaultConnector)", next)
	}
	if len(persistence.insertCalls) != 0 {
		t.Fatalf("Insert calls = %d, want 0", len(persistence.insertCalls))
	}
}

func TestSendNotificationActionSkipsWhenRecipientNotFound(t *testing.T) {
	persistence := &fakeSendNotificationPersistence{users: map[string]models.SObject{}}
	executor := sendNotificationTestExecutor(persistence)
	action := sendNotificationTestAction(
		stringLiteralParam("recipientUserId", "user-missing"),
		stringLiteralParam("title", "Hello"),
	)
	action.FaultConnector = &models.FlowConnector{TargetReference: "OnFault"}
	interview := &models.FlowInterview{ContextData: map[string]interface{}{}}

	next, err := executor.executeActionCall(context.Background(), interview, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
	if err != nil {
		t.Fatalf("executeActionCall() error = %v, want nil (fail-safe skip must not propagate)", err)
	}
	if next != "Next" {
		t.Fatalf("next = %q, want Next (normal connector, FaultConnector must not be consumed by Tier 2)", next)
	}
	if len(persistence.insertCalls) != 0 {
		t.Fatalf("Insert calls = %d, want 0", len(persistence.insertCalls))
	}
}

func TestSendNotificationActionSkipsWhenRecipientInactive(t *testing.T) {
	tests := []struct {
		name     string
		isActive interface{}
	}{
		{"bool false", false},
		{"int64 zero", int64(0)},
		{"string zero", "0"},
		{"float64 zero", float64(0)},
		{"missing field", nil},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			row := models.SObject{constants.FieldID: "user-inactive-1"}
			if tt.isActive != nil {
				row[constants.FieldUser_IsActive] = tt.isActive
			}
			persistence := &fakeSendNotificationPersistence{users: map[string]models.SObject{"user-inactive-1": row}}
			executor := sendNotificationTestExecutor(persistence)
			action := sendNotificationTestAction(
				stringLiteralParam("recipientUserId", "user-inactive-1"),
				stringLiteralParam("title", "Hello"),
			)
			interview := &models.FlowInterview{ContextData: map[string]interface{}{}}

			next, err := executor.executeActionCall(context.Background(), interview, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
			if err != nil {
				t.Fatalf("executeActionCall() error = %v", err)
			}
			if next != "Next" {
				t.Fatalf("next = %q, want Next", next)
			}
			if len(persistence.insertCalls) != 0 {
				t.Fatalf("Insert calls = %d, want 0", len(persistence.insertCalls))
			}
		})
	}
}

func TestSendNotificationActionTreatsTolerantActiveValuesAsActive(t *testing.T) {
	tests := []struct {
		name     string
		isActive interface{}
	}{
		{"bool true", true},
		{"int64 one", int64(1)},
		{"int one", int(1)},
		{"string one", "1"},
		{"float64 one", float64(1)},
		{"string true", "true"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			persistence := &fakeSendNotificationPersistence{
				users: map[string]models.SObject{
					"user-active-1": {constants.FieldID: "user-active-1", constants.FieldUser_IsActive: tt.isActive},
				},
			}
			executor := sendNotificationTestExecutor(persistence)
			action := sendNotificationTestAction(
				stringLiteralParam("recipientUserId", "user-active-1"),
				stringLiteralParam("title", "Hello"),
			)
			interview := &models.FlowInterview{ContextData: map[string]interface{}{}}

			next, err := executor.executeActionCall(context.Background(), interview, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
			if err != nil {
				t.Fatalf("executeActionCall() error = %v", err)
			}
			if next != "Next" {
				t.Fatalf("next = %q, want Next", next)
			}
			if len(persistence.insertCalls) != 1 {
				t.Fatalf("Insert calls = %d, want 1 for active value %#v", len(persistence.insertCalls), tt.isActive)
			}
		})
	}
}

func TestSendNotificationActionSkipsOnRecipientLookupInfraError(t *testing.T) {
	persistence := &fakeSendNotificationPersistence{getRawErr: errors.New("connection reset")}
	executor := sendNotificationTestExecutor(persistence)
	action := sendNotificationTestAction(
		stringLiteralParam("recipientUserId", "user-1"),
		stringLiteralParam("title", "Hello"),
	)
	interview := &models.FlowInterview{ContextData: map[string]interface{}{}}

	next, err := executor.executeActionCall(context.Background(), interview, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
	if err != nil {
		t.Fatalf("executeActionCall() error = %v, want nil (infra failure must not crash the flow)", err)
	}
	if next != "Next" {
		t.Fatalf("next = %q, want Next", next)
	}
	if len(persistence.insertCalls) != 0 {
		t.Fatalf("Insert calls = %d, want 0", len(persistence.insertCalls))
	}
}

func TestSendNotificationActionSkipsOnInsertError(t *testing.T) {
	persistence := &fakeSendNotificationPersistence{
		users: map[string]models.SObject{
			"user-active-1": {constants.FieldID: "user-active-1", constants.FieldUser_IsActive: true},
		},
		insertErr: errors.New("duplicate key"),
	}
	executor := sendNotificationTestExecutor(persistence)
	action := sendNotificationTestAction(
		stringLiteralParam("recipientUserId", "user-active-1"),
		stringLiteralParam("title", "Hello"),
	)
	interview := &models.FlowInterview{ContextData: map[string]interface{}{}}

	next, err := executor.executeActionCall(context.Background(), interview, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
	if err != nil {
		t.Fatalf("executeActionCall() error = %v, want nil (insert failure must not crash the flow)", err)
	}
	if next != "Next" {
		t.Fatalf("next = %q, want Next", next)
	}
	if len(persistence.insertCalls) != 1 {
		t.Fatalf("Insert calls = %d, want 1 (the Tier-2 skip must be driven by an attempted insert that failed, not a short-circuit before it)", len(persistence.insertCalls))
	}
}

// --- Audit fault box visibility ------------------------------------------
//
// These call executeSendNotificationAction directly rather than through
// executeActionCall: executeActionCall installs its OWN fresh audit-fault
// box at its top (withAuditFaultBox shadows any box already on ctx), so a
// box created by the test before calling executeActionCall would never see
// what the handler records. Calling the handler directly is also a more
// precise unit test of the handler's own recordAuditFault contract.

func TestSendNotificationActionStampsAuditFaultOnTier2Skip(t *testing.T) {
	persistence := &fakeSendNotificationPersistence{users: map[string]models.SObject{}}
	executor := sendNotificationTestExecutor(persistence)
	action := sendNotificationTestAction(
		stringLiteralParam("recipientUserId", "user-missing"),
		stringLiteralParam("title", "Hello"),
	)
	interview := &models.FlowInterview{ContextData: map[string]interface{}{}}

	ctx, box := withAuditFaultBox(context.Background())
	_, err := executor.executeSendNotificationAction(ctx, interview, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
	if err != nil {
		t.Fatalf("executeSendNotificationAction() error = %v", err)
	}
	if box.Err() == nil {
		t.Fatal("auditFaultBox.Err() = nil, want the Tier-2 skip recorded so _System_FlowAuditLog is not silently green")
	}
}

func TestSendNotificationActionAuditFaultBoxEmptyOnSuccess(t *testing.T) {
	persistence := &fakeSendNotificationPersistence{
		users: map[string]models.SObject{
			"user-active-1": {constants.FieldID: "user-active-1", constants.FieldUser_IsActive: true},
		},
	}
	executor := sendNotificationTestExecutor(persistence)
	action := sendNotificationTestAction(
		stringLiteralParam("recipientUserId", "user-active-1"),
		stringLiteralParam("title", "Hello"),
	)
	interview := &models.FlowInterview{ContextData: map[string]interface{}{}}

	ctx, box := withAuditFaultBox(context.Background())
	_, err := executor.executeSendNotificationAction(ctx, interview, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
	if err != nil {
		t.Fatalf("executeSendNotificationAction() error = %v", err)
	}
	if box.Err() != nil {
		t.Fatalf("auditFaultBox.Err() = %v, want nil on success", box.Err())
	}
}

// --- SystemContext scoping -------------------------------------------------

func TestSendNotificationActionInsertsUnderSystemContextWithoutLeakingElevation(t *testing.T) {
	persistence := &fakeSendNotificationPersistence{
		users: map[string]models.SObject{
			"user-active-1": {constants.FieldID: "user-active-1", constants.FieldUser_IsActive: true},
		},
	}
	executor := sendNotificationTestExecutor(persistence)
	action := sendNotificationTestAction(
		stringLiteralParam("recipientUserId", "user-active-1"),
		stringLiteralParam("title", "Hello"),
	)
	interview := &models.FlowInterview{ContextData: map[string]interface{}{}}

	callerCtx := context.Background()
	if got := constants.GetSecurityContext(callerCtx); got != constants.UserContext {
		t.Fatalf("precondition: caller ctx security context = %v, want UserContext", got)
	}

	_, err := executor.executeActionCall(callerCtx, interview, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
	if err != nil {
		t.Fatalf("executeActionCall() error = %v", err)
	}
	if len(persistence.insertCalls) != 1 {
		t.Fatalf("Insert calls = %d, want 1", len(persistence.insertCalls))
	}
	if got := persistence.insertCalls[0].securityContext; got < constants.SystemContext {
		t.Fatalf("Insert securityContext = %v, want >= SystemContext", got)
	}
	// The elevation must not leak back onto the caller's own ctx object.
	if got := constants.GetSecurityContext(callerCtx); got != constants.UserContext {
		t.Fatalf("caller ctx security context after call = %v, want still UserContext (no leak)", got)
	}
}

// TestSendNotificationActionInsertSuppressesRecordTriggeredAutomation pins the
// sandbox-escape guard: the Notification row is a system-owned side effect, so
// the insert ctx must carry the targeted suppression tuple that
// FlowTriggerListener honors — otherwise a tenant Flow record-triggered on
// Notification would dispatch under the elevated SystemContext of this write
// and could recurse back into sendNotification. Asserted through the real
// listener matcher (suppressRecordTriggeredAutomation), not a raw ctx-value
// read, so a format drift between writer and listener fails the test.
func TestSendNotificationActionInsertSuppressesRecordTriggeredAutomation(t *testing.T) {
	persistence := &fakeSendNotificationPersistence{
		users: map[string]models.SObject{
			"user-active-1": {constants.FieldID: "user-active-1", constants.FieldUser_IsActive: true},
		},
	}
	executor := sendNotificationTestExecutor(persistence)
	action := sendNotificationTestAction(
		stringLiteralParam("recipientUserId", "user-active-1"),
		stringLiteralParam("title", "Hello"),
	)
	interview := &models.FlowInterview{ContextData: map[string]interface{}{}}

	_, err := executor.executeActionCall(context.Background(), interview, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
	if err != nil {
		t.Fatalf("executeActionCall() error = %v", err)
	}
	if len(persistence.insertCalls) != 1 {
		t.Fatalf("Insert calls = %d, want 1", len(persistence.insertCalls))
	}
	call := persistence.insertCalls[0]

	sideEffectRow := events.RecordEventPayload{ObjectAPIName: constants.TableNotification, Record: call.record}
	if !suppressRecordTriggeredAutomation(call.ctx, sideEffectRow) {
		t.Fatal("insert ctx must suppress record-triggered automation for the notification side-effect row")
	}
	// Suppression is targeted to the inserted row, not a blanket switch.
	otherRow := events.RecordEventPayload{ObjectAPIName: constants.TableNotification, Record: models.SObject{constants.FieldID: "some-other-row"}}
	if suppressRecordTriggeredAutomation(call.ctx, otherRow) {
		t.Fatal("suppression must be scoped to the inserted row id; other rows must stay unsuppressed")
	}
}

// --- Preview mode ------------------------------------------------------

func TestSendNotificationActionPreviewRejected(t *testing.T) {
	persistence := &fakeSendNotificationPersistence{
		users: map[string]models.SObject{
			"user-active-1": {constants.FieldID: "user-active-1", constants.FieldUser_IsActive: true},
		},
	}
	e := &FlowExecutor{
		persistence:        persistence,
		logger:             logging.New(context.Background()),
		actionCallRegistry: newFlowActionCallRegistry(NotificationFlowActionCallRegistrations()...),
	}
	action := sendNotificationTestAction(
		stringLiteralParam("recipientUserId", "user-active-1"),
		stringLiteralParam("title", "Hello"),
	)
	interview := &models.FlowInterview{ContextData: map[string]interface{}{}}

	ctx := WithPreviewMode(context.Background())
	next, err := e.executeActionCall(ctx, interview, &models.FlowMetadata{}, action, &models.UserSession{ID: "u1"})
	if err == nil {
		t.Fatal("executeActionCall() error = nil, want preview rejection")
	}
	if !errors.Is(err, errPreviewActionCallAttempt) {
		t.Fatalf("error = %v, want errPreviewActionCallAttempt", err)
	}
	if next != "" {
		t.Fatalf("next = %q, want empty", next)
	}
	if len(persistence.insertCalls) != 0 {
		t.Fatalf("Insert calls = %d, want 0 (preview must never invoke the handler)", len(persistence.insertCalls))
	}
	if len(persistence.getRawCalls) != 0 {
		t.Fatalf("GetRaw calls = %d, want 0 (preview must never invoke the handler)", len(persistence.getRawCalls))
	}
}

// --- Registry descriptor pinning (policy regression guard) --------------

func TestNotificationFlowActionCallRegistrationPreviewPolicyPinnedToReject(t *testing.T) {
	// previewPersistence.Insert (flow_preview.go) hard-rejects any insert not
	// tagged as a RecordCreate-element insert, so PreviewPolicyAllow /
	// AllowWithPreviewPersistence would surface a confusing internal preview
	// error instead of cleanly no-oping. Upgrading preview support requires
	// preview-engine work, not a policy flip on this registration.
	registrations := NotificationFlowActionCallRegistrations()
	if len(registrations) != 1 {
		t.Fatalf("len(NotificationFlowActionCallRegistrations()) = %d, want 1", len(registrations))
	}
	reg := registrations[0]
	// NotificationFlowActionCallRegistrations() returns the raw (unregistered)
	// declaration; normalization to lowercase happens when a registration is
	// added to a *flowActionCallRegistry (see Register), not here.
	if reg.ActionType != flowSendNotificationActionType {
		t.Fatalf("ActionType = %q, want %q", reg.ActionType, flowSendNotificationActionType)
	}
	if reg.PreviewPolicy != FlowActionCallPreviewPolicyReject {
		t.Fatalf("PreviewPolicy = %v, want FlowActionCallPreviewPolicyReject", reg.PreviewPolicy)
	}
	if reg.AtomicPolicy != FlowActionCallAtomicPolicySafe {
		t.Fatalf("AtomicPolicy = %v, want FlowActionCallAtomicPolicySafe", reg.AtomicPolicy)
	}
	if !reg.ApprovalNotificationSafe {
		t.Fatal("ApprovalNotificationSafe = false, want true (forcing use case: approval sandbox)")
	}
	if reg.SendsEmail {
		t.Fatal("SendsEmail = true, want false")
	}
	if reg.HandlerRef != "platform:automation/send-notification" {
		t.Fatalf("HandlerRef = %q", reg.HandlerRef)
	}
}
