package automation

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"strings"
	"testing"

	"github.com/rs/zerolog"

	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/outbound/externaldelivery"
	"github.com/nexuscrm/outbound/url_safety"
	apperrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/formula"
	"github.com/nexuscrm/kernel/pkg/logging"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

// stubFailClient is a ports.HTTPClient stub that always returns a *url.Error
// with the given raw URL embedded. This simulates a transport failure from
// net/http where the URL (including query params) appears verbatim in the
// error string before any caller-side redaction is applied.
type stubFailClient struct{ rawURL string }

func (s *stubFailClient) Do(*http.Request) (*http.Response, error) {
	return nil, &url.Error{Op: "Get", URL: s.rawURL, Err: fmt.Errorf("connection refused")}
}

// Round 12 codex regression guard: action template expressions like {!Id}
// MUST resolve to the running record's Id, NOT to the running user's Id.
//
// Pre-fix (commits 9650500 → d93f5ef): formulaCtx.User was set to
// BuildFormulaUserContext's __User map and the formula engine flattened
// ctx.User into top-level env AFTER record fields. So env["Id"] ended up
// = user.ID instead of record.Id, and {!Id} (a common action / email-
// template merge field) resolved to the running user instead of the
// target record. This was a real correctness regression that would have
// caused create/update actions to write the wrong lookup value and
// email templates to render the wrong Id.
//
// Post-fix: ctx.User is left nil and the user/profile/role projection
// is exposed via Variables only. Variables flatten writes top-level
// keys (user, __User, __Profile, __UserRole) into env without flattening
// the inner sub-map keys, so env["Id"] from record flatten is preserved.
func TestActionService_EvaluateRef_RecordIdNotClobberedByUser(t *testing.T) {
	as := &ActionService{formula: formula.NewEngine()}

	actionCtx := &ActionContext{
		Record: models.SObject{
			"Id":   "rec-001",
			"Name": "Record Name",
		},
		User: &models.UserSession{
			ID:        "user-002",
			Name:      "User Name",
			ProfileID: "prof-001",
		},
	}

	// Bare {!Id}: resolves to record.Id via top-level env from record flatten.
	got, err := as.evaluateRef(context.Background(), "{!Id}", actionCtx, "")
	if err != nil {
		t.Fatalf("evaluateRef(\"{!Id}\") err = %v", err)
	}
	if got != "rec-001" {
		t.Errorf("evaluateRef(\"{!Id}\") = %v; want %q (record.Id, NOT clobbered by user)", got, "rec-001")
	}

	// Bare {!Name}: same guarantee — record.Name not clobbered by user.Name.
	got, err = as.evaluateRef(context.Background(), "{!Name}", actionCtx, "")
	if err != nil {
		t.Fatalf("evaluateRef(\"{!Name}\") err = %v", err)
	}
	if got != "Record Name" {
		t.Errorf("evaluateRef(\"{!Name}\") = %v; want %q (record.Name, NOT clobbered by user)", got, "Record Name")
	}

	// Legacy {!User.Id}: rewritten by normalizeActionFormulaExpression to
	// `user.Id`, resolves via env["user"]["Id"] (Variables flatten).
	got, err = as.evaluateRef(context.Background(), "{!User.Id}", actionCtx, "")
	if err != nil {
		t.Fatalf("evaluateRef(\"{!User.Id}\") err = %v", err)
	}
	if got != "user-002" {
		t.Errorf("evaluateRef(\"{!User.Id}\") = %v; want %q (user.ID via legacy namespace)", got, "user-002")
	}
}

// Round 3 regression guard (NCRM-INT-10 #1621): executeCallWebhook MUST call
// as.allowlist.RedactError on the transport error before logging or returning
// it. Without the redact call, Go's *url.Error.Error() embeds the raw URL
// verbatim, leaking secrets like ?api_key=... into application logs and error
// chains.
//
// This test uses a stubFailClient that injects a *url.Error carrying
// SECRETVALUE123 in the URL query string, then asserts that the error returned
// by executeCallWebhook does NOT contain SECRETVALUE123. The real
// url_safety.Allowlist is used as the URLAllowlist so RedactError follows the
// production code path (RedactURL strips query params).
func TestExecuteCallWebhook_RedactsSecretFromTransportError(t *testing.T) {
	const secretURL = "https://example.com/hook?api_key=SECRETVALUE123"

	as := &ActionService{
		webhookClient: &stubFailClient{rawURL: secretURL},
		allowlist:     url_safety.NewAllowlist(),
		logger:        logging.New(context.Background()),
		formula:       formula.NewEngine(),
	}

	action := &models.ActionMetadata{
		Config: map[string]interface{}{
			"url":    secretURL,
			"method": "POST",
		},
	}

	err := as.executeCallWebhook(context.Background(), action, &ActionContext{})
	if err == nil {
		t.Fatal("expected error from executeCallWebhook, got nil")
	}

	errStr := err.Error()
	if strings.Contains(errStr, "SECRETVALUE123") {
		t.Errorf("executeCallWebhook leaked secret in error string: %q", errStr)
	}
}

// Action evaluation path with nil user: legacy `user.X` namespace must
// not panic and must resolve nil/empty (matches pre-PR behavior where
// formulaCtx.User was an empty map).
func TestActionService_EvaluateRef_NilUserDoesNotPanic(t *testing.T) {
	as := &ActionService{formula: formula.NewEngine()}

	actionCtx := &ActionContext{
		Record: models.SObject{"Id": "rec-001"},
		User:   nil,
	}

	defer func() {
		if r := recover(); r != nil {
			t.Errorf("evaluateRef with nil user panicked: %v", r)
		}
	}()

	got, err := as.evaluateRef(context.Background(), "{!Id}", actionCtx, "")
	if err != nil {
		t.Fatalf("evaluateRef(\"{!Id}\") with nil user err = %v", err)
	}
	if got != "rec-001" {
		t.Errorf("evaluateRef(\"{!Id}\") with nil user = %v; want %q", got, "rec-001")
	}
}

// Round 5 regression guard (NCRM-INT-10 #1621 Blocker B): executeCallWebhook
// MUST return apperrors.ValidationError with field "url" (not InternalError)
// when the webhook URL violates the url_safety allowlist policy. Before the
// Blocker-B fix, the allowlist rejection was hidden inside WebhookClient.Do
// and reached the caller as an opaque InternalError (HTTP 500). Admin-configured
// URL mistakes (loopback, private range, invalid scheme) must surface as
// ValidationError so the admin self-service path can return a user-facing 4xx.
func TestExecuteCallWebhook_InvalidURL_ReturnsValidationError(t *testing.T) {
	// 127.0.0.1 is a valid URL syntactically but targets a loopback address
	// rejected by the production url_safety allowlist.
	const blockedURL = "https://127.0.0.1/hook"

	as := &ActionService{
		webhookClient: &stubFailClient{rawURL: blockedURL}, // never reached; preflight fires first
		allowlist:     url_safety.NewAllowlist(),
		logger:        logging.New(context.Background()),
		formula:       formula.NewEngine(),
	}

	action := &models.ActionMetadata{
		Config: map[string]interface{}{
			"url":    blockedURL,
			"method": "POST",
		},
	}

	err := as.executeCallWebhook(context.Background(), action, &ActionContext{})
	if err == nil {
		t.Fatal("expected error for blocked URL, got nil")
	}

	var ve *apperrors.ValidationError
	if !errors.As(err, &ve) {
		t.Fatalf("expected ValidationError, got %T: %v", err, err)
	}
	if ve.Field != "url" {
		t.Errorf("expected ValidationError.Field == %q, got %q", "url", ve.Field)
	}
}

// ── Delivery recorder hook tests ────────────────────────────────────────────

// fakeAttemptRecorder captures RecordAttempt calls.
type fakeAttemptRecorder struct {
	calls []externaldelivery.AttemptWrite
	err   error
}

func (r *fakeAttemptRecorder) RecordAttempt(_ context.Context, in externaldelivery.AttemptWrite) (string, error) {
	r.calls = append(r.calls, in)
	return "attempt-id", r.err
}

// stubEmailSenderForHook is a minimal ports.EmailSender that records calls and
// returns a preset error.
type stubEmailSenderForHook struct {
	err  error
	msgs []*ports.EmailMessage
}

func (s *stubEmailSenderForHook) Send(_ context.Context, msg *ports.EmailMessage) (*ports.EmailSendResult, error) {
	s.msgs = append(s.msgs, msg)
	if s.err != nil {
		return nil, s.err
	}
	return &ports.EmailSendResult{SentCount: 1}, nil
}

// minimalEmailPersistenceStub implements PersistenceProvider for email hook
// tests. Only GetRaw is implemented; all other methods panic. GetRaw returns
// an active email template row for any (table, id) pair.
type minimalEmailPersistenceStub struct{}

func (p *minimalEmailPersistenceStub) GetRaw(_ context.Context, _ string, _ string) (models.SObject, error) {
	return models.SObject{
		constants.FieldSysEmailTemplate_IsActive:  true,
		constants.FieldSysEmailTemplate_Subject:   "Hello",
		constants.FieldSysEmailTemplate_HTMLValue: "<p>Hello</p>",
		constants.FieldSysEmailTemplate_Body:      "Hello",
	}, nil
}

func (p *minimalEmailPersistenceStub) Insert(_ context.Context, _ string, _ models.SObject, _ *models.UserSession) (models.SObject, error) {
	panic("minimalEmailPersistenceStub.Insert: not implemented")
}
func (p *minimalEmailPersistenceStub) PreviewBeforeInsert(_ context.Context, _ string, _ models.SObject, _ *models.UserSession) (models.SObject, error) {
	panic("minimalEmailPersistenceStub.PreviewBeforeInsert: not implemented")
}
func (p *minimalEmailPersistenceStub) PrelockUpdateParents(_ context.Context, _ string, _ string, _ models.SObject, _ *models.UserSession) error {
	panic("minimalEmailPersistenceStub.PrelockUpdateParents: not implemented")
}
func (p *minimalEmailPersistenceStub) Update(_ context.Context, _ string, _ string, _ models.SObject, _ *models.UserSession) error {
	panic("minimalEmailPersistenceStub.Update: not implemented")
}
func (p *minimalEmailPersistenceStub) PreviewBeforeUpdate(_ context.Context, _ string, _ string, _ models.SObject, _ models.SObject, _ *models.UserSession) (*models.PreparedRecordUpdate, error) {
	panic("minimalEmailPersistenceStub.PreviewBeforeUpdate: not implemented")
}
func (p *minimalEmailPersistenceStub) CommitPreparedUpdate(_ context.Context, _ string, _ string, _ *models.PreparedRecordUpdate, _ models.SObject, _ *models.UserSession) error {
	panic("minimalEmailPersistenceStub.CommitPreparedUpdate: not implemented")
}
func (p *minimalEmailPersistenceStub) Delete(_ context.Context, _ string, _ string, _ *models.UserSession) error {
	panic("minimalEmailPersistenceStub.Delete: not implemented")
}
func (p *minimalEmailPersistenceStub) GetLock(_ context.Context, _ string, _ string) (models.SObject, error) {
	panic("minimalEmailPersistenceStub.GetLock: not implemented")
}
func (p *minimalEmailPersistenceStub) GetRecordType(_ context.Context, _ string) (string, error) {
	panic("minimalEmailPersistenceStub.GetRecordType: not implemented")
}
func (p *minimalEmailPersistenceStub) RunInTransaction(_ context.Context, fn func(context.Context) error) error {
	panic("minimalEmailPersistenceStub.RunInTransaction: not implemented")
}
func (p *minimalEmailPersistenceStub) IsTransactionActive(_ context.Context) bool { return false }
func (p *minimalEmailPersistenceStub) RunInPessimisticTransaction(_ context.Context, fn func(context.Context) error) error {
	panic("minimalEmailPersistenceStub.RunInPessimisticTransaction: not implemented")
}
func (p *minimalEmailPersistenceStub) DetachTx(ctx context.Context) context.Context { return ctx }
func (p *minimalEmailPersistenceStub) RecomputeActiveFormulaProjection(_ context.Context, _ *models.UserSession, _, _ string, _ []string) (models.SObject, error) {
	return nil, nil
}

// TestEmailHook_RecordsSuccessOnSend verifies that a successful send produces
// a status=success attempt row with the correct metadata.
func TestEmailHook_RecordsSuccessOnSend(t *testing.T) {
	recorder := &fakeAttemptRecorder{}
	sender := &stubEmailSenderForHook{}
	as := &ActionService{
		formula:         formula.NewEngine(),
		emailSender:     sender,
		persistence:     &minimalEmailPersistenceStub{},
		attemptRecorder: recorder,
		logger:          logging.New(context.Background()),
	}
	req := canonicalSendEmailRequest{
		TemplateID:    "tmpl-001",
		To:            []string{"test@example.com"},
		ObjectAPIName: "Lead",
		ActionID:      "action-ok",
	}
	_, err := as.executeCanonicalSendEmail(context.Background(), req)
	if err != nil {
		t.Fatalf("unexpected error from executeCanonicalSendEmail: %v", err)
	}
	if len(recorder.calls) != 1 {
		t.Fatalf("expected 1 attempt row, got %d", len(recorder.calls))
	}
	call := recorder.calls[0]
	if call.Status != externaldelivery.StatusSuccess {
		t.Errorf("status = %q, want success", call.Status)
	}
	if call.Channel != externaldelivery.ChannelEmail {
		t.Errorf("channel = %q, want email", call.Channel)
	}
	if call.EmailPayload == nil {
		t.Fatal("EmailPayload is nil")
	}
	if call.ActionID != "action-ok" {
		t.Errorf("ActionID = %q, want action-ok (C3)", call.ActionID)
	}
	// TargetSummary is metadata-tier (exposed by the admin list/detail API):
	// recipient-count shape only, raw addresses live in the BLOCKED payload.
	if call.TargetSummary != "1 recipients" {
		t.Errorf("TargetSummary = %q, want the recipient-count shape", call.TargetSummary)
	}
	if strings.Contains(call.TargetSummary, "@") {
		t.Errorf("TargetSummary %q leaks a raw address", call.TargetSummary)
	}
}

// TestEmailHook_RecordsFailedOnSendError verifies a failed send produces
// a status=failed attempt row, the error is still returned to the caller, and
// the PERSISTED ErrorMessage is the sanitized recipient-count shape — provider
// transport errors can embed rendered recipients/subject/body, which must
// never reach the row (the admin list/detail API exposes ErrorMessage).
func TestEmailHook_RecordsFailedOnSendError(t *testing.T) {
	recorder := &fakeAttemptRecorder{}
	sendErr := errors.New("smtp rejected fail@example.com subject Private Sensitive Plan body Sensitive Variable")
	sender := &stubEmailSenderForHook{err: sendErr}
	as := &ActionService{
		formula:         formula.NewEngine(),
		emailSender:     sender,
		persistence:     &minimalEmailPersistenceStub{},
		attemptRecorder: recorder,
		logger:          logging.New(context.Background()),
	}
	req := canonicalSendEmailRequest{
		TemplateID:    "tmpl-001",
		To:            []string{"fail@example.com"},
		ObjectAPIName: "Lead",
		ActionID:      "action-fail",
	}
	_, err := as.executeCanonicalSendEmail(context.Background(), req)
	if err == nil {
		t.Fatal("expected error from executeCanonicalSendEmail when send fails")
	}
	if len(recorder.calls) != 1 {
		t.Fatalf("expected 1 attempt row after failed send, got %d", len(recorder.calls))
	}
	last := recorder.calls[0]
	if last.Status != externaldelivery.StatusFailed {
		t.Errorf("status = %q, want failed", last.Status)
	}
	if !strings.Contains(last.ErrorMessage, "failed to send email to 1 recipients") {
		t.Errorf("ErrorMessage = %q, want sanitized recipient-count failure", last.ErrorMessage)
	}
	if strings.Contains(last.ErrorMessage, "Sensitive") || strings.Contains(last.ErrorMessage, "fail@example.com") {
		t.Errorf("ErrorMessage %q leaks provider transport detail", last.ErrorMessage)
	}
}

// TestEmailHook_DoubleFailureLogIsSanitized verifies the double-failure path
// (provider send failed AND attempt-row write failed) logs only the sanitized
// errMsg — the raw provider error can embed rendered recipient/subject/body
// content and must never reach the logs.
func TestEmailHook_DoubleFailureLogIsSanitized(t *testing.T) {
	var buf bytes.Buffer
	logCtx := zerolog.New(&buf).WithContext(context.Background())
	recorder := &fakeAttemptRecorder{err: errors.New("db down")}
	sender := &stubEmailSenderForHook{err: errors.New("smtp rejected sensitive-recipient@example.com subject Private Sensitive Plan body Sensitive Variable")}
	as := &ActionService{
		formula:         formula.NewEngine(),
		emailSender:     sender,
		persistence:     &minimalEmailPersistenceStub{},
		attemptRecorder: recorder,
		logger:          logging.New(logCtx),
	}
	req := canonicalSendEmailRequest{
		TemplateID:    "tmpl-001",
		To:            []string{"sensitive-recipient@example.com"},
		ObjectAPIName: "Lead",
		ActionID:      "action-double-fail",
	}
	_, err := as.executeCanonicalSendEmail(context.Background(), req)
	if err == nil {
		t.Fatal("expected error from executeCanonicalSendEmail when send fails")
	}
	logs := buf.String()
	if !strings.Contains(logs, "attempt record write failed") {
		t.Fatalf("double-failure log line missing; logs = %q", logs)
	}
	for _, fragment := range []string{"Sensitive", "sensitive-recipient@example.com", "smtp rejected"} {
		if strings.Contains(logs, fragment) {
			t.Errorf("logs leak provider transport detail %q; logs = %q", fragment, logs)
		}
	}
	if !strings.Contains(logs, "failed to send email to 1 recipients") {
		t.Errorf("logs should carry the sanitized recipient-count shape; logs = %q", logs)
	}
}

// TestWebhookHook_RecordsOnAllThreeReturnPaths verifies C1: all three
// return paths (transport error, status>=400, success) produce an attempt row.
func TestWebhookHook_RecordsOnAllThreeReturnPaths(t *testing.T) {
	allowlist := url_safety.NewAllowlist()

	t.Run("success_path", func(t *testing.T) {
		recorder := &fakeAttemptRecorder{}
		client := &stubHTTPClientForHook{statusCode: 200}
		as := &ActionService{
			formula:         formula.NewEngine(),
			webhookClient:   client,
			allowlist:       allowlist,
			logger:          logging.New(context.Background()),
			attemptRecorder: recorder,
		}
		topLevelConfig := map[string]interface{}{
			"url": "https://example.com/hook",
		}
		err := as.executeCallWebhook(context.Background(), &models.ActionMetadata{
			ID:            "wh-action",
			ObjectAPIName: "Lead",
			Config:        topLevelConfig,
		}, &ActionContext{})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if len(recorder.calls) != 1 {
			t.Fatalf("expected 1 attempt, got %d", len(recorder.calls))
		}
		if recorder.calls[0].Status != externaldelivery.StatusSuccess {
			t.Errorf("status = %q, want success", recorder.calls[0].Status)
		}
		// Top-level rows freeze the full-config fingerprint too: resend
		// refuses unless the current action config is byte-identical.
		wp := recorder.calls[0].WebhookPayload
		if wp == nil {
			t.Fatal("WebhookPayload is nil")
		}
		if wp.StepID != "" {
			t.Errorf("StepID = %q, want empty for top-level", wp.StepID)
		}
		if wp.StepFingerprint != as.stepConfigFingerprint(topLevelConfig) {
			t.Errorf("StepFingerprint = %q, want the frozen full-config hash", wp.StepFingerprint)
		}
	})

	t.Run("status_400_path", func(t *testing.T) {
		recorder := &fakeAttemptRecorder{}
		client := &stubHTTPClientForHook{statusCode: 400}
		as := &ActionService{
			formula:         formula.NewEngine(),
			webhookClient:   client,
			allowlist:       allowlist,
			logger:          logging.New(context.Background()),
			attemptRecorder: recorder,
		}
		err := as.executeCallWebhook(context.Background(), &models.ActionMetadata{
			ID:            "wh-action",
			ObjectAPIName: "Lead",
			Config: map[string]interface{}{
				"url": "https://example.com/hook",
			},
		}, &ActionContext{})
		if err == nil {
			t.Fatal("expected error for 400 response")
		}
		if len(recorder.calls) != 1 {
			t.Fatalf("expected 1 attempt, got %d", len(recorder.calls))
		}
		if recorder.calls[0].Status != externaldelivery.StatusFailed {
			t.Errorf("status = %q, want failed", recorder.calls[0].Status)
		}
		if recorder.calls[0].ResponseStatus == nil || *recorder.calls[0].ResponseStatus != 400 {
			t.Errorf("ResponseStatus = %v, want 400", recorder.calls[0].ResponseStatus)
		}
	})

	t.Run("transport_error_path", func(t *testing.T) {
		recorder := &fakeAttemptRecorder{}
		client := &stubHTTPClientForHook{err: errors.New("connection refused")}
		as := &ActionService{
			formula:         formula.NewEngine(),
			webhookClient:   client,
			allowlist:       allowlist,
			logger:          logging.New(context.Background()),
			attemptRecorder: recorder,
		}
		err := as.executeCallWebhook(context.Background(), &models.ActionMetadata{
			ID:            "wh-action",
			ObjectAPIName: "Lead",
			Config: map[string]interface{}{
				"url": "https://example.com/hook",
			},
		}, &ActionContext{})
		if err == nil {
			t.Fatal("expected error for transport failure")
		}
		if len(recorder.calls) != 1 {
			t.Fatalf("expected 1 attempt, got %d", len(recorder.calls))
		}
		if recorder.calls[0].Status != externaldelivery.StatusFailed {
			t.Errorf("status = %q, want failed", recorder.calls[0].Status)
		}
	})
}

// TestWebhookHook_NilRecorder_NoOp ensures a nil recorder does not panic.
func TestWebhookHook_NilRecorder_NoOp(t *testing.T) {
	allowlist := url_safety.NewAllowlist()
	client := &stubHTTPClientForHook{statusCode: 200}
	as := &ActionService{
		formula:       formula.NewEngine(),
		webhookClient: client,
		allowlist:     allowlist,
		logger:        logging.New(context.Background()),
		// attemptRecorder deliberately nil
	}
	err := as.executeCallWebhook(context.Background(), &models.ActionMetadata{
		ID:            "wh-nil",
		ObjectAPIName: "Lead",
		Config: map[string]interface{}{
			"url": "https://example.com/hook",
		},
	}, &ActionContext{})
	if err != nil {
		t.Fatalf("nil recorder should not cause error: %v", err)
	}
}

// TestActionID_ThreadedIntoWebhookAttempt verifies C3 for the webhook path:
// action.ID is threaded into the attempt row. NOTE: the Flow sendEmail path
// (buildCanonicalSendEmailRequest in executor_actions.go) records an empty
// SourceActionId because FlowActionCall carries no stable action ID — that is a
// documented limitation (see PR body), not exercised here.
func TestActionID_ThreadedIntoWebhookAttempt(t *testing.T) {
	recorder := &fakeAttemptRecorder{}
	allowlist := url_safety.NewAllowlist()
	client := &stubHTTPClientForHook{statusCode: 200}
	as := &ActionService{
		formula:         formula.NewEngine(),
		webhookClient:   client,
		allowlist:       allowlist,
		logger:          logging.New(context.Background()),
		attemptRecorder: recorder,
	}
	_ = as // for action_id threading test; we use webhook path here
	err := as.executeCallWebhook(context.Background(), &models.ActionMetadata{
		ID:            "action-thread-test",
		ObjectAPIName: "Lead",
		Config: map[string]interface{}{
			"url": "https://example.com/hook",
		},
	}, &ActionContext{})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(recorder.calls) != 1 {
		t.Fatalf("expected 1 attempt, got %d", len(recorder.calls))
	}
	if recorder.calls[0].ActionID != "action-thread-test" {
		t.Errorf("ActionID = %q, want action-thread-test (C3)", recorder.calls[0].ActionID)
	}
}

// TestSendWebhookRaw_Non2xxReturnsError verifies the gate0-blocker fix: a webhook
// resend that gets a 4xx/5xx is a delivery failure (error + real status), so the
// admin resend path records StatusFailed and returns non-2xx — matching
// executeCallWebhook, not a false StatusSuccess + HTTP 200.
func TestSendWebhookRaw_Non2xxReturnsError(t *testing.T) {
	as := &ActionService{
		webhookClient: &stubHTTPClientForHook{statusCode: 503},
		allowlist:     url_safety.NewAllowlist(),
		logger:        logging.New(context.Background()),
	}
	status, err := as.SendWebhookRaw(context.Background(), "https://example.com/hook", "POST", nil, []byte(`{}`))
	if err == nil {
		t.Fatalf("expected an error for a 503 response, got nil")
	}
	if status != 503 {
		t.Errorf("status = %d, want 503 (real code returned alongside the error)", status)
	}
}

// TestSendWebhookRaw_2xxSucceeds is the success counterpart: a 2xx response
// returns the status with no error.
func TestSendWebhookRaw_2xxSucceeds(t *testing.T) {
	as := &ActionService{
		webhookClient: &stubHTTPClientForHook{statusCode: 200},
		allowlist:     url_safety.NewAllowlist(),
		logger:        logging.New(context.Background()),
	}
	status, err := as.SendWebhookRaw(context.Background(), "https://example.com/hook", "POST", nil, []byte(`{}`))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if status != 200 {
		t.Errorf("status = %d, want 200", status)
	}
}

// TestWebhookHook_DoubleFailure_RecordsAndPropagates verifies gate0 r4 D2: when
// BOTH the webhook send fails (5xx) AND the attempt-record write fails, the hook
// still records the failed attempt, does not panic, and propagates the send
// error — the double-failure is never silently dropped (recordErr is logged on
// the failure path too, not only on the success path).
func TestWebhookHook_DoubleFailure_RecordsAndPropagates(t *testing.T) {
	recorder := &fakeAttemptRecorder{err: errors.New("db down")}
	as := &ActionService{
		webhookClient:   &stubHTTPClientForHook{statusCode: 503},
		allowlist:       url_safety.NewAllowlist(),
		formula:         formula.NewEngine(),
		logger:          logging.New(context.Background()),
		attemptRecorder: recorder,
	}
	err := as.executeCallWebhook(context.Background(), &models.ActionMetadata{
		ID:            "wh-doublefail",
		ObjectAPIName: "Lead",
		Config:        map[string]interface{}{"url": "https://example.com/hook"},
	}, &ActionContext{})
	if err == nil {
		t.Fatal("expected the webhook send error to propagate, got nil")
	}
	// The failed attempt is still recorded even though the record write errored;
	// the double-failure is not silently dropped.
	if len(recorder.calls) != 1 {
		t.Fatalf("expected 1 attempt record call, got %d", len(recorder.calls))
	}
	if recorder.calls[0].Status != externaldelivery.StatusFailed {
		t.Errorf("status = %q, want failed", recorder.calls[0].Status)
	}
}

// TestWebhookAttempt_TargetSummarySanitized verifies that the stored
// TargetSummary and WebhookPayload.URL are both host-only (so url-query
// secrets such as ?api_key=… are never persisted); the live wire request
// still uses the full raw url.
func TestWebhookAttempt_TargetSummarySanitized(t *testing.T) {
	recorder := &fakeAttemptRecorder{}
	allowlist := url_safety.NewAllowlist()
	capturingClient := &capturingHTTPClient{statusCode: 200}
	as := &ActionService{
		formula:         formula.NewEngine(),
		webhookClient:   capturingClient,
		allowlist:       allowlist,
		logger:          logging.New(context.Background()),
		attemptRecorder: recorder,
	}
	rawURL := "https://user:pass@example.com/services/T/B/SECRET?api_key=SECRET123"
	err := as.executeCallWebhook(context.Background(), &models.ActionMetadata{
		ID:            "action-sanitize-test",
		ObjectAPIName: "Lead",
		Config: map[string]interface{}{
			"url": rawURL,
		},
	}, &ActionContext{})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(recorder.calls) != 1 {
		t.Fatalf("expected 1 attempt, got %d", len(recorder.calls))
	}
	got := recorder.calls[0]
	// TargetSummary is host-only: Redact drops userinfo + path + query/fragment,
	// all of which can carry credentials (user:pass@, /services/T/B/SECRET, ?api_key=).
	for _, secret := range []string{"user:pass", "SECRET", "api_key"} {
		if strings.Contains(got.TargetSummary, secret) {
			t.Errorf("TargetSummary leaked %q, got %q", secret, got.TargetSummary)
		}
	}
	if got.TargetSummary != "https://example.com" {
		t.Errorf("TargetSummary = %q, want https://example.com (host-only)", got.TargetSummary)
	}
	// WebhookPayload.URL must also be host-only (not the raw url): url
	// userinfo/path/query are credential positions and must never be persisted.
	if got.WebhookPayload == nil {
		t.Fatal("WebhookPayload must not be nil")
	}
	if got.WebhookPayload.URL != "https://example.com" {
		t.Errorf("WebhookPayload.URL = %q, want https://example.com (host-only, credential-stripped)", got.WebhookPayload.URL)
	}
	// Marshaled stored payload JSON must not contain secret url parts.
	payloadJSON, err := json.Marshal(got.WebhookPayload)
	if err != nil {
		t.Fatalf("failed to marshal stored payload: %v", err)
	}
	payloadStr := string(payloadJSON)
	for _, secret := range []string{"user:pass", "SECRET", "api_key"} {
		if strings.Contains(payloadStr, secret) {
			t.Errorf("stored payload JSON leaked %q: %s", secret, payloadStr)
		}
	}
	// The wire request must still hit the full raw url (SSRF re-validation uses it).
	if capturingClient.lastReq == nil {
		t.Fatal("no request was captured by the HTTP client")
	}
	if capturingClient.lastReq.URL.String() != rawURL {
		t.Errorf("wire request URL = %q, want %q (full raw url for wire send)", capturingClient.lastReq.URL.String(), rawURL)
	}
}

// TestWebhookAttempt_StoredHeadersScrubbed verifies the at-rest credential
// isolation: wire request carries real header values, stored attempt row has
// values scrubbed except Content-Type.
func TestWebhookAttempt_StoredHeadersScrubbed(t *testing.T) {
	recorder := &fakeAttemptRecorder{}
	allowlist := url_safety.NewAllowlist()
	capturingClient := &capturingHTTPClient{statusCode: 200}
	as := &ActionService{
		formula:         formula.NewEngine(),
		webhookClient:   capturingClient,
		allowlist:       allowlist,
		logger:          logging.New(context.Background()),
		attemptRecorder: recorder,
	}
	err := as.executeCallWebhook(context.Background(), &models.ActionMetadata{
		ID:            "action-scrub-test",
		ObjectAPIName: "Lead",
		Config: map[string]interface{}{
			"url":    "https://example.com/hook",
			"method": "POST",
			"headers": map[string]interface{}{
				"Authorization": "Bearer SECRET_TOKEN",
				"X-Api-Key":     "KEY123",
			},
		},
	}, &ActionContext{})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	// (a) Wire request must carry REAL Authorization and X-Api-Key values.
	if capturingClient.lastReq == nil {
		t.Fatal("no request was captured by the HTTP client")
	}
	if got := capturingClient.lastReq.Header.Get("Authorization"); got != "Bearer SECRET_TOKEN" {
		t.Errorf("wire Authorization = %q, want Bearer SECRET_TOKEN (real value sent on wire)", got)
	}
	if got := capturingClient.lastReq.Header.Get("X-Api-Key"); got != "KEY123" {
		t.Errorf("wire X-Api-Key = %q, want KEY123 (real value sent on wire)", got)
	}

	if len(recorder.calls) != 1 {
		t.Fatalf("expected 1 attempt, got %d", len(recorder.calls))
	}
	stored := recorder.calls[0]
	if stored.WebhookPayload == nil {
		t.Fatal("WebhookPayload is nil")
	}

	// (b) Stored attempt header values for Authorization and X-Api-Key must be scrubbed.
	if got := stored.WebhookPayload.Headers["Authorization"]; got != externaldelivery.StoredHeaderScrubSentinel {
		t.Errorf("stored Authorization = %q, want %q", got, externaldelivery.StoredHeaderScrubSentinel)
	}
	if got := stored.WebhookPayload.Headers["X-Api-Key"]; got != externaldelivery.StoredHeaderScrubSentinel {
		t.Errorf("stored X-Api-Key = %q, want %q", got, externaldelivery.StoredHeaderScrubSentinel)
	}

	// (c) Content-Type must keep its real value.
	if got := stored.WebhookPayload.Headers["Content-Type"]; got != "application/json" {
		t.Errorf("stored Content-Type = %q, want application/json", got)
	}

	// (d) Marshaled JSON of stored payload must not contain secret values.
	payloadJSON, err := json.Marshal(stored.WebhookPayload)
	if err != nil {
		t.Fatalf("failed to marshal stored payload: %v", err)
	}
	payloadStr := string(payloadJSON)
	for _, secret := range []string{"SECRET_TOKEN", "KEY123"} {
		if strings.Contains(payloadStr, secret) {
			t.Errorf("stored payload JSON leaked secret %q: %s", secret, payloadStr)
		}
	}
}

// TestResolveWebhookTarget_EmptyActionID returns a validation error.
func TestResolveWebhookTarget_EmptyActionID(t *testing.T) {
	as := &ActionService{
		formula:   formula.NewEngine(),
		allowlist: url_safety.NewAllowlist(),
	}
	_, err := as.ResolveWebhookTarget(context.Background(), "", "", "")
	if err == nil {
		t.Fatal("expected error for empty actionID, got nil")
	}
	var ve *apperrors.ValidationError
	if !errors.As(err, &ve) {
		t.Fatalf("expected ValidationError, got %T: %v", err, err)
	}
	if ve.Field != "actionId" {
		t.Errorf("ValidationError.Field = %q, want actionId", ve.Field)
	}
}

// TestResolveWebhookTarget_UnknownActionID returns a not-found error.
func TestResolveWebhookTarget_UnknownActionID(t *testing.T) {
	as := &ActionService{
		formula:      formula.NewEngine(),
		allowlist:    url_safety.NewAllowlist(),
		actionLookup: &stubActionByIDer{action: nil},
	}
	_, err := as.ResolveWebhookTarget(context.Background(), "nonexistent-action", "", "")
	if err == nil {
		t.Fatal("expected error for unknown actionID, got nil")
	}
	var nfe *apperrors.NotFoundError
	if !errors.As(err, &nfe) {
		t.Fatalf("expected NotFoundError, got %T: %v", err, err)
	}
}

// TestResolveWebhookTarget_HappyPath returns the full url, host-only RedactedURL, and
// Content-Type plus configured headers.
func TestResolveWebhookTarget_HappyPath(t *testing.T) {
	rawURL := "https://hooks.example.com/services/T/B/SECRET?api_key=K"
	action := &models.ActionMetadata{
		ID:   "action-resolve",
		Type: constants.ActionTypeCallWebhook,
		Config: map[string]interface{}{
			"url":    rawURL,
			"method": "POST",
			"headers": map[string]interface{}{
				"Authorization": "Bearer LIVE_TOKEN",
				"X-Custom":      "custom-value",
			},
		},
	}
	as := &ActionService{
		formula:      formula.NewEngine(),
		allowlist:    url_safety.NewAllowlist(),
		actionLookup: &stubActionByIDer{action: action},
	}
	// Top-level resend requires the frozen full-config fingerprint to match
	// the current config (identity-by-equality at every addressing level).
	target, err := as.ResolveWebhookTarget(context.Background(), "action-resolve", "", as.stepConfigFingerprint(action.Config))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if target.URL != rawURL {
		t.Errorf("URL = %q, want %q (full url for wire send)", target.URL, rawURL)
	}
	if target.RedactedURL != "https://hooks.example.com" {
		t.Errorf("RedactedURL = %q, want https://hooks.example.com (host-only)", target.RedactedURL)
	}
	if target.Headers["Content-Type"] != "application/json" {
		t.Errorf("Content-Type = %q, want application/json", target.Headers["Content-Type"])
	}
	if target.Headers["Authorization"] != "Bearer LIVE_TOKEN" {
		t.Errorf("Authorization = %q, want Bearer LIVE_TOKEN (real value for wire send)", target.Headers["Authorization"])
	}
	if target.Headers["X-Custom"] != "custom-value" {
		t.Errorf("X-Custom = %q, want custom-value", target.Headers["X-Custom"])
	}
}

// TestResolveWebhookTarget_TemplatedURLFailsClosed verifies that a config url
// containing a formula reference ({!...}) fails closed — it cannot be
// re-evaluated without the original record context.
func TestResolveWebhookTarget_TemplatedURLFailsClosed(t *testing.T) {
	action := &models.ActionMetadata{
		ID:   "action-templated",
		Type: constants.ActionTypeCallWebhook,
		Config: map[string]interface{}{
			"url":    "{!Webhook_URL__c}",
			"method": "POST",
		},
	}
	as := &ActionService{
		formula:      formula.NewEngine(),
		allowlist:    url_safety.NewAllowlist(),
		actionLookup: &stubActionByIDer{action: action},
	}
	_, err := as.ResolveWebhookTarget(context.Background(), "action-templated", "", as.stepConfigFingerprint(action.Config))
	if err == nil {
		t.Fatal("expected error for record-templated url, got nil")
	}
	if !strings.Contains(err.Error(), "record-templated") {
		t.Errorf("error message should mention record-templated, got: %v", err)
	}
}

// ── Helper stubs ────────────────────────────────────────────────────────────

// stubHTTPClientForHook is a minimal HTTPClient stub returning a fixed status or error.
type stubHTTPClientForHook struct {
	statusCode int
	err        error
}

func (s *stubHTTPClientForHook) Do(*http.Request) (*http.Response, error) {
	if s.err != nil {
		return nil, s.err
	}
	return &http.Response{
		StatusCode: s.statusCode,
		Body:       http.NoBody,
	}, nil
}

// capturingHTTPClient captures the last *http.Request for assertion in tests.
type capturingHTTPClient struct {
	statusCode int
	lastReq    *http.Request
}

func (c *capturingHTTPClient) Do(req *http.Request) (*http.Response, error) {
	c.lastReq = req
	return &http.Response{
		StatusCode: c.statusCode,
		Body:       http.NoBody,
	}, nil
}

// TestCompositeWebhookAttempt_StoresRootActionIDAndStepID verifies that when a
// callWebhook step executes inside a composite action, the recorded attempt has
// ActionID equal to the composite's persisted id and WebhookPayload.StepID equal
// to the local step id. This ensures operator resend can resolve the step via
// ResolveWebhookTarget(compositeID, stepID) rather than getting a not-found error
// from a synthesized local id.
func TestCompositeWebhookAttempt_StoresRootActionIDAndStepID(t *testing.T) {
	allowlist := url_safety.NewAllowlist()
	recorder := &fakeAttemptRecorder{}
	client := &stubHTTPClientForHook{statusCode: 200}
	txm := &recordingActionTxManager{}
	as := &ActionService{
		formula:         formula.NewEngine(),
		webhookClient:   client,
		allowlist:       allowlist,
		logger:          logging.New(context.Background()),
		attemptRecorder: recorder,
		txManager:       txm,
	}

	compositeAction := &models.ActionMetadata{
		ID:            "composite-1",
		ObjectAPIName: "Lead",
		Type:          constants.ActionTypeComposite,
		Config: map[string]interface{}{
			constants.ConfigKeySteps: []interface{}{
				map[string]interface{}{
					constants.FieldID:             "step-wh",
					constants.FieldSysAction_Type: constants.ActionTypeCallWebhook,
					constants.ConfigURL:           "https://example.com/hook",
					constants.ConfigMethod:        "POST",
					constants.ConfigHeaders: map[string]interface{}{
						"X-Custom": "custom-value",
					},
				},
			},
		},
	}

	actionCtx := &ActionContext{
		Record:  models.SObject{"Id": "lead-1"},
		User:    &models.UserSession{ID: "005-user"},
		Results: make(map[string]map[string]interface{}),
	}

	err := as.executeComposite(context.Background(), compositeAction, actionCtx)
	if err != nil {
		t.Fatalf("executeComposite: %v", err)
	}
	if len(recorder.calls) != 1 {
		t.Fatalf("expected 1 attempt, got %d", len(recorder.calls))
	}
	got := recorder.calls[0]
	// ActionID must reference the persisted composite row, not the local step id.
	if got.ActionID != "composite-1" {
		t.Errorf("attempt.ActionID = %q, want composite-1 (persisted root)", got.ActionID)
	}
	if got.WebhookPayload == nil {
		t.Fatal("attempt.WebhookPayload is nil")
	}
	// StepID must be the local step id so resend can locate the step's config.
	if got.WebhookPayload.StepID != "step-wh" {
		t.Errorf("attempt.WebhookPayload.StepID = %q, want step-wh", got.WebhookPayload.StepID)
	}
	// URL must be stored host-only (scrubbed).
	if got.WebhookPayload.URL != "https://example.com" {
		t.Errorf("stored url = %q, want https://example.com (host-only)", got.WebhookPayload.URL)
	}
	// Secret header values must be scrubbed.
	if got.WebhookPayload.Headers["X-Custom"] != externaldelivery.StoredHeaderScrubSentinel {
		t.Errorf("stored X-Custom = %q, want scrubbed sentinel", got.WebhookPayload.Headers["X-Custom"])
	}
}

// TestResolveWebhookTarget_StepAddressing is a table-driven test for the
// step-addressing path of ResolveWebhookTarget.
func TestResolveWebhookTarget_StepAddressing(t *testing.T) {
	allowlist := url_safety.NewAllowlist()

	compositeConfigWithOneWebhookStep := map[string]interface{}{
		constants.ConfigKeySteps: []interface{}{
			map[string]interface{}{
				constants.FieldID:             "step-wh",
				constants.FieldSysAction_Type: constants.ActionTypeCallWebhook,
				constants.ConfigURL:           "https://hooks.example.com/services/T/B/SECRET",
				constants.ConfigHeaders: map[string]interface{}{
					"Authorization": "Bearer LIVE_TOKEN",
				},
			},
		},
	}

	compositeConfigWithDuplicateStepIDs := map[string]interface{}{
		constants.ConfigKeySteps: []interface{}{
			map[string]interface{}{
				constants.FieldID:             "step-dup",
				constants.FieldSysAction_Type: constants.ActionTypeCallWebhook,
				constants.ConfigURL:           "https://a.example.com/hook",
			},
			map[string]interface{}{
				constants.FieldID:             "step-dup",
				constants.FieldSysAction_Type: constants.ActionTypeCallWebhook,
				constants.ConfigURL:           "https://b.example.com/hook",
			},
		},
	}

	compositeConfigWithWrongType := map[string]interface{}{
		constants.ConfigKeySteps: []interface{}{
			map[string]interface{}{
				constants.FieldID:             "step-email",
				constants.FieldSysAction_Type: constants.ActionTypeSendEmail,
				constants.ConfigEmailTemplate: "tpl-1",
			},
		},
	}

	// Nested: outer composite → inner composite step → webhook step.
	compositeConfigNested := map[string]interface{}{
		constants.ConfigKeySteps: []interface{}{
			map[string]interface{}{
				constants.FieldID:             "inner-composite",
				constants.FieldSysAction_Type: constants.ActionTypeComposite,
				constants.ConfigKeySteps: []interface{}{
					map[string]interface{}{
						constants.FieldID:             "nested-wh",
						constants.FieldSysAction_Type: constants.ActionTypeCallWebhook,
						constants.ConfigURL:           "https://nested.example.com/hook",
					},
				},
			},
		},
	}

	// Cross-level duplicates: one top-level match plus two more inside a nested
	// composite. Uniqueness must be judged over the WHOLE tree — a per-subtree
	// check would swallow the nested ambiguity and silently resolve to the
	// outer match (fail-open).
	compositeConfigCrossLevelDuplicates := map[string]interface{}{
		constants.ConfigKeySteps: []interface{}{
			map[string]interface{}{
				constants.FieldID:             "step-dup",
				constants.FieldSysAction_Type: constants.ActionTypeCallWebhook,
				constants.ConfigURL:           "https://outer.example.com/hook",
			},
			map[string]interface{}{
				constants.FieldID:             "inner-composite",
				constants.FieldSysAction_Type: constants.ActionTypeComposite,
				constants.ConfigKeySteps: []interface{}{
					map[string]interface{}{
						constants.FieldID:             "step-dup",
						constants.FieldSysAction_Type: constants.ActionTypeCallWebhook,
						constants.ConfigURL:           "https://inner-a.example.com/hook",
					},
					map[string]interface{}{
						constants.FieldID:             "step-dup",
						constants.FieldSysAction_Type: constants.ActionTypeCallWebhook,
						constants.ConfigURL:           "https://inner-b.example.com/hook",
					},
				},
			},
		},
	}

	tests := []struct {
		name       string
		actionID   string
		stepID     string
		config     map[string]interface{}
		wantURL    string
		wantErrSub string // non-empty → expect error containing this substring
	}{
		{
			name:     "happy_step_found",
			actionID: "composite-1",
			stepID:   "step-wh",
			config:   compositeConfigWithOneWebhookStep,
			wantURL:  "https://hooks.example.com/services/T/B/SECRET",
		},
		{
			name:       "step_not_found",
			actionID:   "composite-1",
			stepID:     "nonexistent-step",
			config:     compositeConfigWithOneWebhookStep,
			wantErrSub: "not found",
		},
		{
			name:       "step_id_ambiguous",
			actionID:   "composite-1",
			stepID:     "step-dup",
			config:     compositeConfigWithDuplicateStepIDs,
			wantErrSub: "ambiguous",
		},
		{
			name:       "step_exists_but_wrong_type",
			actionID:   "composite-1",
			stepID:     "step-email",
			config:     compositeConfigWithWrongType,
			wantErrSub: "not found",
		},
		{
			name:     "nested_composite_resolves",
			actionID: "composite-root",
			stepID:   "nested-wh",
			config:   compositeConfigNested,
			wantURL:  "https://nested.example.com/hook",
		},
		{
			name:       "cross_level_duplicates_ambiguous",
			actionID:   "composite-root",
			stepID:     "step-dup",
			config:     compositeConfigCrossLevelDuplicates,
			wantErrSub: "ambiguous",
		},
		{
			// Top-level (stepID=="") with a retyped action (Type no longer
			// CallWebhook): must fail closed even if stale config still has a
			// url key. The type check fires before webhookTargetFromConfig.
			name:     "top_level_retyped_action_fails_closed",
			actionID: "action-retyped-toplevel",
			stepID:   "",
			config: map[string]interface{}{
				"url":    "https://example.com/hook",
				"method": "POST",
			},
			wantErrSub: "no longer a callWebhook",
		},
		{
			// Step addressing (stepID!="") with a root action retyped away from
			// Composite: must fail closed even if stale steps JSON is still present
			// in Config. The root-type guard fires before findUniqueWebhookStepConfig.
			name:       "root_retyped_non_composite_fails_closed",
			actionID:   "action-retyped-composite",
			stepID:     "step-wh",
			config:     compositeConfigWithOneWebhookStep, // stale steps JSON still present
			wantErrSub: "no longer a composite action",
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			actionType := constants.ActionTypeComposite
			if tc.stepID == "" {
				// Top-level cases use the action's own type to drive the type check.
				// The "retyped" case sets SendEmail; all other top-level cases already
				// set stepID!="" (composite) so this branch is only reached for the
				// new retyped test and any future top-level cases.
				if strings.Contains(tc.name, "retyped") {
					actionType = constants.ActionTypeSendEmail
				} else {
					actionType = constants.ActionTypeCallWebhook
				}
			} else if strings.Contains(tc.name, "root_retyped_non_composite") {
				// stepID is set but the root is retyped away from Composite to
				// SendEmail — this exercises the new root-type guard.
				actionType = constants.ActionTypeSendEmail
			}
			action := &models.ActionMetadata{
				ID:     tc.actionID,
				Type:   actionType,
				Config: tc.config,
			}
			as := &ActionService{
				formula:      formula.NewEngine(),
				allowlist:    allowlist,
				actionLookup: &stubActionByIDer{action: action},
			}
			// For step-addressed success cases, compute the fingerprint from the
			// current step config so the call mirrors production behaviour.
			// Error cases pass "" — fingerprint is never reached when uniqueness/
			// type checks fail first.
			stepFingerprint := ""
			if tc.stepID != "" && tc.wantErrSub == "" {
				// Locate the step config to compute the expected fingerprint.
				if sc, ferr := findUniqueStepConfigOfType(tc.config, tc.stepID, constants.ActionTypeCallWebhook); ferr == nil {
					stepFingerprint = as.stepConfigFingerprint(sc)
				}
			}
			target, err := as.ResolveWebhookTarget(context.Background(), tc.actionID, tc.stepID, stepFingerprint)
			if tc.wantErrSub != "" {
				if err == nil {
					t.Fatalf("expected error containing %q, got nil (target=%+v)", tc.wantErrSub, target)
				}
				if !strings.Contains(err.Error(), tc.wantErrSub) {
					t.Errorf("error = %q, want substring %q", err.Error(), tc.wantErrSub)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if target.URL != tc.wantURL {
				t.Errorf("URL = %q, want %q", target.URL, tc.wantURL)
			}
		})
	}

	// Also assert that happy-path step resolves current headers correctly.
	t.Run("happy_step_headers", func(t *testing.T) {
		action := &models.ActionMetadata{
			ID:     "composite-hdr",
			Type:   constants.ActionTypeComposite,
			Config: compositeConfigWithOneWebhookStep,
		}
		as := &ActionService{
			formula:      formula.NewEngine(),
			allowlist:    allowlist,
			actionLookup: &stubActionByIDer{action: action},
		}
		// Compute the fingerprint from the current step config so the call
		// mirrors production behaviour.
		stepCfg, _ := findUniqueStepConfigOfType(compositeConfigWithOneWebhookStep, "step-wh", constants.ActionTypeCallWebhook)
		fp := as.stepConfigFingerprint(stepCfg)
		target, err := as.ResolveWebhookTarget(context.Background(), "composite-hdr", "step-wh", fp)
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if target.Headers["Authorization"] != "Bearer LIVE_TOKEN" {
			t.Errorf("Authorization = %q, want Bearer LIVE_TOKEN (step's current config)", target.Headers["Authorization"])
		}
		if target.Headers["Content-Type"] != "application/json" {
			t.Errorf("Content-Type = %q, want application/json", target.Headers["Content-Type"])
		}
	})
}

// stubActionByIDer satisfies actionByIDer for ResolveWebhookTarget and
// EnsureResendActionAuthority unit tests.
// Returns (action, nil) normally; returns (nil, err) when err is set to
// simulate a transient DB read failure; returns (nil, nil) when both are nil
// (→ not-found or soft-deleted path).
type stubActionByIDer struct {
	action *models.ActionMetadata
	err    error
}

func (s *stubActionByIDer) GetLiveActionByID(_ context.Context, _ string) (*models.ActionMetadata, error) {
	return s.action, s.err
}

// ── H1: buildWebhookHeaders canonicalization ─────────────────────────────────

// TestBuildWebhookHeaders_CanonicalOverride verifies that an admin-configured
// "content-type" (non-canonical casing) deterministically overrides the default
// Content-Type entry and that all header names are canonicalized.
func TestBuildWebhookHeaders_CanonicalOverride(t *testing.T) {
	config := map[string]interface{}{
		constants.ConfigHeaders: map[string]interface{}{
			"content-type": "text/xml", // non-canonical: must override the default
			"x-api-key":    "K",        // non-canonical: must be canonicalized
		},
	}
	result := buildWebhookHeaders(config)

	// Exactly one Content-Type entry (no duplicate-casing keys).
	if _, hasLower := result["content-type"]; hasLower {
		t.Errorf("result still has non-canonical key \"content-type\"; want only \"Content-Type\"")
	}
	ctVal, hasCT := result["Content-Type"]
	if !hasCT {
		t.Fatal("result missing canonical Content-Type key")
	}
	if ctVal != "text/xml" {
		t.Errorf("Content-Type = %q, want \"text/xml\" (admin override)", ctVal)
	}

	// x-api-key must be canonicalized to X-Api-Key.
	if _, hasLower := result["x-api-key"]; hasLower {
		t.Errorf("result still has non-canonical key \"x-api-key\"; want \"X-Api-Key\"")
	}
	if got := result[http.CanonicalHeaderKey("x-api-key")]; got != "K" {
		t.Errorf("X-Api-Key = %q, want \"K\"", got)
	}

	// No spurious extra keys: map must have exactly Content-Type + X-Api-Key.
	if len(result) != 2 {
		t.Errorf("len(result) = %d, want 2 (no duplicate-casing entries): %v", len(result), result)
	}
}

// ── H2: noop email transport ──────────────────────────────────────────────────

// TestEmailAttempt_NoopTransportRecordsNotDelivered verifies that when
// emailTransportNotDelivering is set, a succeeding fake sender still causes the
// attempt row to be recorded as StatusFailed with a mention of not-delivered,
// while the action execution itself returns no error (funnel behavior unchanged).
// Also confirms that with the flag false the same fixture records success.
func TestEmailAttempt_NoopTransportRecordsNotDelivered(t *testing.T) {
	t.Run("noop_flag_set_records_failed", func(t *testing.T) {
		recorder := &fakeAttemptRecorder{}
		sender := &stubEmailSenderForHook{} // succeeds (err == nil)
		as := &ActionService{
			formula:                     formula.NewEngine(),
			emailSender:                 sender,
			persistence:                 &minimalEmailPersistenceStub{},
			attemptRecorder:             recorder,
			logger:                      logging.New(context.Background()),
			emailTransportNotDelivering: true,
		}
		req := canonicalSendEmailRequest{
			TemplateID:    "tmpl-001",
			To:            []string{"test@example.com"},
			ObjectAPIName: "Lead",
			ActionID:      "action-noop",
		}
		// (a) Action execution must return NO error — funnel behavior unchanged.
		_, err := as.executeCanonicalSendEmail(context.Background(), req)
		if err != nil {
			t.Fatalf("executeCanonicalSendEmail must not return error in noop mode, got: %v", err)
		}
		if len(recorder.calls) != 1 {
			t.Fatalf("expected 1 attempt row, got %d", len(recorder.calls))
		}
		call := recorder.calls[0]
		// (b) Attempt status must be StatusFailed.
		if call.Status != externaldelivery.StatusFailed {
			t.Errorf("status = %q, want failed (noop transport must not record success)", call.Status)
		}
		// (c) Error message must mention non-delivery / SMTP_HOST.
		if !strings.Contains(call.ErrorMessage, "NOT delivered") && !strings.Contains(call.ErrorMessage, "SMTP_HOST") {
			t.Errorf("ErrorMessage %q must mention not-delivered or SMTP_HOST", call.ErrorMessage)
		}
	})

	t.Run("noop_flag_unset_records_success", func(t *testing.T) {
		recorder := &fakeAttemptRecorder{}
		sender := &stubEmailSenderForHook{}
		as := &ActionService{
			formula:         formula.NewEngine(),
			emailSender:     sender,
			persistence:     &minimalEmailPersistenceStub{},
			attemptRecorder: recorder,
			logger:          logging.New(context.Background()),
			// emailTransportNotDelivering is false (zero value)
		}
		req := canonicalSendEmailRequest{
			TemplateID:    "tmpl-001",
			To:            []string{"test@example.com"},
			ObjectAPIName: "Lead",
			ActionID:      "action-real",
		}
		_, err := as.executeCanonicalSendEmail(context.Background(), req)
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if len(recorder.calls) != 1 {
			t.Fatalf("expected 1 attempt row, got %d", len(recorder.calls))
		}
		if recorder.calls[0].Status != externaldelivery.StatusSuccess {
			t.Errorf("status = %q, want success when flag is unset", recorder.calls[0].Status)
		}
	})
}

// TestSendRenderedEmail_NoopTransportReturnsError verifies that SendRenderedEmail
// still calls the underlying sender once but returns the sentinel error when
// emailTransportNotDelivering is true.
func TestSendRenderedEmail_NoopTransportReturnsError(t *testing.T) {
	sender := &stubEmailSenderForHook{} // succeeds (err == nil)
	as := &ActionService{
		emailSender:                 sender,
		emailTransportNotDelivering: true,
	}
	msg := &ports.EmailMessage{To: []string{"ops@example.com"}, Subject: "Hi"}
	result, err := as.SendRenderedEmail(context.Background(), msg)
	// Sender must have been called exactly once.
	if len(sender.msgs) != 1 {
		t.Fatalf("sender.Send called %d times, want 1", len(sender.msgs))
	}
	// Must return the sentinel error.
	if err == nil {
		t.Fatal("expected sentinel error from SendRenderedEmail in noop mode, got nil")
	}
	if !strings.Contains(err.Error(), "NOT delivered") && !strings.Contains(err.Error(), "SMTP_HOST") {
		t.Errorf("sentinel error %q should mention not-delivered or SMTP_HOST", err.Error())
	}
	// result is still returned (non-nil) from the underlying noop send.
	_ = result
}

// TestSendRenderedEmail_SanitizesProviderError verifies the resend replay
// helper never returns the raw provider transport error: the resend write-site
// persists this error into the attempt row (whose ErrorMessage the admin
// list/detail API exposes), so only the recipient-count shape may escape —
// the same contract as the canonical send funnel.
func TestSendRenderedEmail_SanitizesProviderError(t *testing.T) {
	sender := &stubEmailSenderForHook{err: errors.New("smtp rejected sensitive-recipient@example.com subject Private Sensitive Plan body Sensitive Variable")}
	as := &ActionService{emailSender: sender}
	msg := &ports.EmailMessage{
		To:      []string{"a@example.com", "b@example.com"},
		Cc:      []string{"c@example.com"},
		Subject: "Private Sensitive Plan",
	}
	_, err := as.SendRenderedEmail(context.Background(), msg)
	if err == nil {
		t.Fatal("expected error from SendRenderedEmail when the provider fails")
	}
	if !strings.Contains(err.Error(), "failed to send email to 3 recipients") {
		t.Errorf("error = %q, want sanitized recipient-count failure", err.Error())
	}
	if strings.Contains(err.Error(), "Sensitive") || strings.Contains(err.Error(), "sensitive-recipient@example.com") {
		t.Errorf("error %q leaks provider transport detail", err.Error())
	}
}

// ── EnsureResendActionAuthority ───────────────────────────────────────────────

// TestEnsureResendActionAuthority verifies authority = existence AND current
// Type still authorizes the email channel:
//   - empty actionID → validation error (flow-emitted email not resendable, fail closed)
//   - whitespace actionID → validation error (same fail-closed)
//   - unknown actionID (stub returns nil) → not-found error
//   - existing SendEmail action, no stepID → nil
//   - action retyped away from SendEmail, no stepID → error containing "no longer a sendEmail"
//   - composite WITH a SendEmail step in Config steps, no stepID → error (no step address)
//   - composite WITHOUT any SendEmail step, no stepID → error (no step address)
//   - composite with a NESTED composite containing a SendEmail step, no stepID → error (no step address)
func TestEnsureResendActionAuthority(t *testing.T) {
	sendEmailAction := &models.ActionMetadata{
		ID:   "action-exists",
		Type: constants.ActionTypeSendEmail,
		Config: map[string]interface{}{
			constants.ConfigEmailTemplate: "tmpl-1",
		},
	}

	retypedAction := &models.ActionMetadata{
		ID:   "action-retyped",
		Type: constants.ActionTypeCallWebhook,
	}

	compositeWithEmailStep := &models.ActionMetadata{
		ID:   "composite-has-email",
		Type: constants.ActionTypeComposite,
		Config: map[string]interface{}{
			constants.ConfigKeySteps: []interface{}{
				map[string]interface{}{
					constants.FieldID:             "step-email",
					constants.FieldSysAction_Type: constants.ActionTypeSendEmail,
					constants.ConfigEmailTemplate: "tmpl-1",
				},
			},
		},
	}

	compositeWithoutEmailStep := &models.ActionMetadata{
		ID:   "composite-no-email",
		Type: constants.ActionTypeComposite,
		Config: map[string]interface{}{
			constants.ConfigKeySteps: []interface{}{
				map[string]interface{}{
					constants.FieldID:             "step-wh",
					constants.FieldSysAction_Type: constants.ActionTypeCallWebhook,
				},
			},
		},
	}

	compositeNestedEmail := &models.ActionMetadata{
		ID:   "composite-nested-email",
		Type: constants.ActionTypeComposite,
		Config: map[string]interface{}{
			constants.ConfigKeySteps: []interface{}{
				map[string]interface{}{
					constants.FieldID:             "inner-composite",
					constants.FieldSysAction_Type: constants.ActionTypeComposite,
					constants.ConfigKeySteps: []interface{}{
						map[string]interface{}{
							constants.FieldID:             "nested-email",
							constants.FieldSysAction_Type: constants.ActionTypeSendEmail,
							constants.ConfigEmailTemplate: "tmpl-1",
						},
					},
				},
			},
		},
	}

	tests := []struct {
		name       string
		actionID   string
		stepID     string
		stubAction *models.ActionMetadata // what GetLiveActionByID returns
		stubErr    error                  // simulates a transient DB read failure
		wantErr    bool
		wantNFE    bool   // expect NotFoundError specifically
		wantIE     bool   // expect InternalError specifically (read failure)
		wantErrSub string // non-empty → expect error containing this substring
	}{
		{
			// empty actionID now fails closed — flow-emitted email is not resendable.
			name:       "empty_id_fails_closed",
			actionID:   "",
			stubAction: nil, // irrelevant — lookup is bypassed for empty id
			wantErr:    true,
			wantErrSub: "flow-emitted email is not resendable",
		},
		{
			// whitespace-only actionID similarly fails closed.
			name:       "whitespace_id_fails_closed",
			actionID:   "   ",
			stubAction: nil,
			wantErr:    true,
			wantErrSub: "flow-emitted email is not resendable",
		},
		{
			// stub returns nil → models both "not found" AND "soft-deleted" cases:
			// GetLiveActionByID returns nil for both a missing action and a
			// soft-deleted action (IsDeleted=true filtered by the live query).
			name:       "soft_deleted_or_missing_refused",
			actionID:   "action-gone",
			stubAction: nil,
			wantErr:    true,
			wantNFE:    true,
		},
		{
			// stub returns an error → transient DB read failure must not be
			// misreported as "action gone" (not-found); must be InternalError.
			name:     "db_read_error_returns_internal_error_not_not_found",
			actionID: "action-db-fail",
			stubErr:  errors.New("connection reset by peer"),
			wantErr:  true,
			wantIE:   true,
		},
		{
			name:       "existing_sendemail_action_no_step_ok",
			actionID:   "action-exists",
			stepID:     "",
			stubAction: sendEmailAction,
			wantErr:    false,
		},
		{
			name:       "retyped_action_refused",
			actionID:   "action-retyped",
			stepID:     "",
			stubAction: retypedAction,
			wantErr:    true,
			wantErrSub: "no longer a sendEmail",
		},
		{
			// composite with no stepID → fail closed: no step address provided.
			name:       "composite_no_step_address_refused",
			actionID:   "composite-has-email",
			stepID:     "",
			stubAction: compositeWithEmailStep,
			wantErr:    true,
			wantErrSub: "no step address",
		},
		{
			// composite with no stepID and no email step → also fail closed.
			name:       "composite_no_step_no_email_refused",
			actionID:   "composite-no-email",
			stepID:     "",
			stubAction: compositeWithoutEmailStep,
			wantErr:    true,
			wantErrSub: "no step address",
		},
		{
			// composite with a stepID that resolves to a sendEmail step → ok.
			name:       "composite_with_valid_step_ok",
			actionID:   "composite-has-email",
			stepID:     "step-email",
			stubAction: compositeWithEmailStep,
			wantErr:    false,
		},
		{
			// composite with a stepID that does not exist → error.
			name:       "composite_step_not_found_refused",
			actionID:   "composite-has-email",
			stepID:     "step-nonexistent",
			stubAction: compositeWithEmailStep,
			wantErr:    true,
			wantErrSub: "step not found",
		},
		{
			// composite with nested email step, addressed by step id → ok.
			name:       "composite_nested_email_step_addressed_ok",
			actionID:   "composite-nested-email",
			stepID:     "nested-email",
			stubAction: compositeNestedEmail,
			wantErr:    false,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			as := &ActionService{
				actionLookup: &stubActionByIDer{action: tc.stubAction, err: tc.stubErr},
				// Template-active gate needs a persistence: the stub returns an
				// active template for any id (the cleared-template fail-closed
				// branch has its own dedicated tests below).
				persistence: &minimalEmailPersistenceStub{},
			}
			// For step-addressed success cases, compute the fingerprint from the
			// actual step config so the call mirrors production behaviour.
			// Error cases pass "" — errors are triggered before fingerprint check.
			stepFingerprint := ""
			if tc.stepID != "" && !tc.wantErr && tc.stubAction != nil {
				if sc, ferr := findUniqueStepConfigOfType(tc.stubAction.Config, tc.stepID, constants.ActionTypeSendEmail); ferr == nil {
					stepFingerprint = as.stepConfigFingerprint(sc)
				}
			}
			err := as.EnsureResendActionAuthority(context.Background(), tc.actionID, tc.stepID, stepFingerprint)
			if tc.wantErr {
				if err == nil {
					t.Fatalf("expected error, got nil")
				}
				if tc.wantNFE {
					var nfe *apperrors.NotFoundError
					if !errors.As(err, &nfe) {
						t.Errorf("expected NotFoundError, got %T: %v", err, err)
					}
				}
				if tc.wantIE {
					var ie *apperrors.InternalError
					if !errors.As(err, &ie) {
						t.Errorf("expected InternalError (not NotFoundError) for DB read failure, got %T: %v", err, err)
					}
					// must NOT be a NotFoundError — that would misreport transient failure as "action gone"
					var nfe *apperrors.NotFoundError
					if errors.As(err, &nfe) {
						t.Errorf("DB read failure must not produce NotFoundError, got NotFoundError: %v", err)
					}
				}
				if tc.wantErrSub != "" && !strings.Contains(err.Error(), tc.wantErrSub) {
					t.Errorf("error = %q, want substring %q", err.Error(), tc.wantErrSub)
				}
			} else {
				if err != nil {
					t.Fatalf("expected nil error, got %v", err)
				}
			}
		})
	}
}

// TestEnsureResendActionAuthority_SendEmail_TemplateInactive verifies that a
// SendEmail action whose template is inactive causes EnsureResendActionAuthority
// to return a validation error, refusing the resend at authority-check time.
func TestEnsureResendActionAuthority_SendEmail_TemplateInactive(t *testing.T) {
	sendEmailActionWithTemplate := &models.ActionMetadata{
		ID:   "action-email-tmpl",
		Type: constants.ActionTypeSendEmail,
		Config: map[string]interface{}{
			constants.ConfigEmailTemplate: "tmpl-inactive",
		},
	}

	t.Run("inactive_template_refused", func(t *testing.T) {
		persistence := &inactiveTemplatePersistenceStub{inactive: true}
		as := &ActionService{
			actionLookup: &stubActionByIDer{action: sendEmailActionWithTemplate},
			persistence:  persistence,
		}
		// stepID="" → top-level sendEmail action.
		err := as.EnsureResendActionAuthority(context.Background(), "action-email-tmpl", "", "")
		if err == nil {
			t.Fatal("expected error for inactive template, got nil")
		}
		var ve *apperrors.ValidationError
		if !errors.As(err, &ve) {
			t.Errorf("expected ValidationError, got %T: %v", err, err)
		}
		if !strings.Contains(err.Error(), "inactive") {
			t.Errorf("error %q should mention inactive", err.Error())
		}
	})

	t.Run("active_template_passes", func(t *testing.T) {
		persistence := &inactiveTemplatePersistenceStub{inactive: false}
		as := &ActionService{
			actionLookup: &stubActionByIDer{action: sendEmailActionWithTemplate},
			persistence:  persistence,
		}
		err := as.EnsureResendActionAuthority(context.Background(), "action-email-tmpl", "", "")
		if err != nil {
			t.Fatalf("expected nil error for active template, got %v", err)
		}
	})

	t.Run("template_db_read_error_returns_internal_error", func(t *testing.T) {
		persistence := &inactiveTemplatePersistenceStub{err: errors.New("db timeout")}
		as := &ActionService{
			actionLookup: &stubActionByIDer{action: sendEmailActionWithTemplate},
			persistence:  persistence,
		}
		err := as.EnsureResendActionAuthority(context.Background(), "action-email-tmpl", "", "")
		if err == nil {
			t.Fatal("expected error for template read failure, got nil")
		}
		var ie *apperrors.InternalError
		if !errors.As(err, &ie) {
			t.Errorf("expected InternalError for template read failure, got %T: %v", err, err)
		}
	})
}

// TestResolveWebhookTarget_ReadError verifies that when GetLiveActionByID
// returns a DB error (not nil/not-found), ResolveWebhookTarget returns an
// InternalError — not a NotFoundError that would misreport a transient failure.
func TestResolveWebhookTarget_ReadError(t *testing.T) {
	as := &ActionService{
		formula:      formula.NewEngine(),
		allowlist:    url_safety.NewAllowlist(),
		actionLookup: &stubActionByIDer{err: errors.New("connection reset")},
	}
	_, err := as.ResolveWebhookTarget(context.Background(), "action-db-fail", "", "")
	if err == nil {
		t.Fatal("expected error for DB read failure, got nil")
	}
	var ie *apperrors.InternalError
	if !errors.As(err, &ie) {
		t.Errorf("expected InternalError for DB read failure, got %T: %v", err, err)
	}
	// must NOT be a NotFoundError
	var nfe *apperrors.NotFoundError
	if errors.As(err, &nfe) {
		t.Errorf("DB read failure must not produce NotFoundError; got: %v", err)
	}
}

// TestCompositeEmailAttempt_FreezesStepID verifies that when a sendEmail action
// runs as a composite step, the attempt row's EmailPayload.StepID is frozen
// to the local step id (symmetric with the webhook path in
// TestCompositeWebhookAttempt_StoresRootActionIDAndStepID).
func TestCompositeEmailAttempt_FreezesStepID(t *testing.T) {
	recorder := &fakeAttemptRecorder{}
	sender := &stubEmailSenderForHook{}
	as := &ActionService{
		formula:         formula.NewEngine(),
		emailSender:     sender,
		persistence:     &minimalEmailPersistenceStub{},
		attemptRecorder: recorder,
		logger:          logging.New(context.Background()),
	}
	// Simulate executeSendEmail being called within a composite context
	// where CompositeRootActionID differs from the local action ID.
	req := canonicalSendEmailRequest{
		TemplateID:    "tmpl-001",
		To:            []string{"test@example.com"},
		ObjectAPIName: "Lead",
		ActionID:      "composite-root-1",
		StepID:        "step-email-1", // frozen composite-step address
	}
	_, err := as.executeCanonicalSendEmail(context.Background(), req)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(recorder.calls) != 1 {
		t.Fatalf("expected 1 attempt row, got %d", len(recorder.calls))
	}
	call := recorder.calls[0]
	if call.EmailPayload == nil {
		t.Fatal("EmailPayload is nil")
	}
	if call.EmailPayload.StepID != "step-email-1" {
		t.Errorf("EmailPayload.StepID = %q, want step-email-1 (frozen composite-step address)", call.EmailPayload.StepID)
	}
	if call.ActionID != "composite-root-1" {
		t.Errorf("ActionID = %q, want composite-root-1", call.ActionID)
	}
}

// TestEnsureResendActionAuthority_StepAddressed verifies step-addressed
// EnsureResendActionAuthority: composite + valid stepID → nil; missing
// step → error; ambiguous stepID → error; step retyped to webhook → error.
func TestEnsureResendActionAuthority_StepAddressed(t *testing.T) {
	compositeAction := &models.ActionMetadata{
		ID:   "composite-1",
		Type: constants.ActionTypeComposite,
		Config: map[string]interface{}{
			constants.ConfigKeySteps: []interface{}{
				map[string]interface{}{
					constants.FieldID:             "step-email",
					constants.FieldSysAction_Type: constants.ActionTypeSendEmail,
					constants.ConfigEmailTemplate: "tmpl-1",
				},
				map[string]interface{}{
					constants.FieldID:             "step-wh",
					constants.FieldSysAction_Type: constants.ActionTypeCallWebhook,
				},
			},
		},
	}
	compositeDupStep := &models.ActionMetadata{
		ID:   "composite-dup",
		Type: constants.ActionTypeComposite,
		Config: map[string]interface{}{
			constants.ConfigKeySteps: []interface{}{
				map[string]interface{}{
					constants.FieldID:             "step-dup",
					constants.FieldSysAction_Type: constants.ActionTypeSendEmail,
				},
				map[string]interface{}{
					constants.FieldID:             "step-dup",
					constants.FieldSysAction_Type: constants.ActionTypeSendEmail,
				},
			},
		},
	}

	tests := []struct {
		name       string
		actionID   string
		stepID     string
		stubAction *models.ActionMetadata
		wantErr    bool
		wantErrSub string
	}{
		{
			name:       "valid_step_ok",
			actionID:   "composite-1",
			stepID:     "step-email",
			stubAction: compositeAction,
			wantErr:    false,
		},
		{
			name:       "step_not_found_refused",
			actionID:   "composite-1",
			stepID:     "step-gone",
			stubAction: compositeAction,
			wantErr:    true,
			wantErrSub: "step not found",
		},
		{
			name:       "step_retyped_to_webhook_refused",
			actionID:   "composite-1",
			stepID:     "step-wh", // step exists but is a webhook, not sendEmail
			stubAction: compositeAction,
			wantErr:    true,
			wantErrSub: "step not found", // filtered out by type
		},
		{
			name:       "ambiguous_step_refused",
			actionID:   "composite-dup",
			stepID:     "step-dup",
			stubAction: compositeDupStep,
			wantErr:    true,
			wantErrSub: "ambiguous",
		},
		{
			// top-level sendEmail action with non-empty stepID → error: type mismatch
			name:     "toplevel_sendemail_with_stepid_refused",
			actionID: "action-se",
			stepID:   "step-1",
			stubAction: &models.ActionMetadata{
				ID:   "action-se",
				Type: constants.ActionTypeSendEmail,
			},
			wantErr:    true,
			wantErrSub: "no longer a composite",
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			as := &ActionService{
				actionLookup: &stubActionByIDer{action: tc.stubAction},
				persistence:  &minimalEmailPersistenceStub{},
			}
			// For step-addressed success cases, compute the fingerprint from the
			// actual step config so the call mirrors production behaviour.
			// Error cases pass "" — errors are triggered before fingerprint check.
			stepFingerprint := ""
			if tc.stepID != "" && !tc.wantErr && tc.stubAction != nil {
				if sc, ferr := findUniqueStepConfigOfType(tc.stubAction.Config, tc.stepID, constants.ActionTypeSendEmail); ferr == nil {
					stepFingerprint = as.stepConfigFingerprint(sc)
				}
			}
			err := as.EnsureResendActionAuthority(context.Background(), tc.actionID, tc.stepID, stepFingerprint)
			if tc.wantErr {
				if err == nil {
					t.Fatalf("expected error, got nil")
				}
				if tc.wantErrSub != "" && !strings.Contains(err.Error(), tc.wantErrSub) {
					t.Errorf("error = %q, want substring %q", err.Error(), tc.wantErrSub)
				}
			} else {
				if err != nil {
					t.Fatalf("expected nil, got %v", err)
				}
			}
		})
	}
}

// TestEnsureResendActionAuthority_ClearedTemplateFailsClosed verifies the
// cleared-template branch: a sendEmail action (or composite email step) whose
// CURRENT config no longer references an email template no longer authorizes
// resend — clearing the template is how an admin disables the email, and the
// live send path cannot send without one. Nil persistence likewise fails
// closed: authority that cannot be verified is not granted.
func TestEnsureResendActionAuthority_ClearedTemplateFailsClosed(t *testing.T) {
	t.Run("toplevel_cleared_template_refused", func(t *testing.T) {
		as := &ActionService{
			actionLookup: &stubActionByIDer{action: &models.ActionMetadata{
				ID:   "action-no-tmpl",
				Type: constants.ActionTypeSendEmail,
				// Config has no emailTemplate — admin cleared it.
			}},
			persistence: &minimalEmailPersistenceStub{},
		}
		// stepID="" → top-level, stepFingerprint ignored.
		err := as.EnsureResendActionAuthority(context.Background(), "action-no-tmpl", "", "")
		if err == nil {
			t.Fatal("expected error for cleared template, got nil")
		}
		var ve *apperrors.ValidationError
		if !errors.As(err, &ve) {
			t.Errorf("expected ValidationError, got %T: %v", err, err)
		}
		if !strings.Contains(err.Error(), "no longer references an email template") {
			t.Errorf("error = %q, want cleared-template message", err.Error())
		}
	})

	t.Run("composite_step_cleared_template_refused", func(t *testing.T) {
		stepCfg := map[string]interface{}{
			constants.FieldID:             "step-email",
			constants.FieldSysAction_Type: constants.ActionTypeSendEmail,
			// No emailTemplate on the step — cleared.
		}
		as := &ActionService{
			allowlist: url_safety.NewAllowlist(),
			actionLookup: &stubActionByIDer{action: &models.ActionMetadata{
				ID:   "composite-no-tmpl",
				Type: constants.ActionTypeComposite,
				Config: map[string]interface{}{
					constants.ConfigKeySteps: []interface{}{stepCfg},
				},
			}},
			persistence: &minimalEmailPersistenceStub{},
		}
		// Supply a correct fingerprint so the check passes and the cleared-template
		// gate is reached (the fingerprint check fires before template-active).
		fp := as.stepConfigFingerprint(stepCfg)
		err := as.EnsureResendActionAuthority(context.Background(), "composite-no-tmpl", "step-email", fp)
		if err == nil {
			t.Fatal("expected error for cleared step template, got nil")
		}
		if !strings.Contains(err.Error(), "no longer references an email template") {
			t.Errorf("error = %q, want cleared-template message", err.Error())
		}
	})

	t.Run("nil_persistence_fails_closed", func(t *testing.T) {
		as := &ActionService{
			actionLookup: &stubActionByIDer{action: &models.ActionMetadata{
				ID:   "action-tmpl",
				Type: constants.ActionTypeSendEmail,
				Config: map[string]interface{}{
					constants.ConfigEmailTemplate: "tmpl-1",
				},
			}},
			// persistence deliberately nil — the gate cannot verify the template.
		}
		// stepID="" → top-level, stepFingerprint ignored.
		err := as.EnsureResendActionAuthority(context.Background(), "action-tmpl", "", "")
		if err == nil {
			t.Fatal("expected error for nil persistence, got nil")
		}
		var ie *apperrors.InternalError
		if !errors.As(err, &ie) {
			t.Errorf("expected InternalError, got %T: %v", err, err)
		}
	})
}

// inactiveTemplatePersistenceStub is a minimal PersistenceProvider stub for
// testing EnsureResendActionAuthority's template-active gate. GetRaw returns
// either an inactive template row, an active template row, or an error
// depending on the stub fields.
type inactiveTemplatePersistenceStub struct {
	inactive bool
	err      error
}

func (p *inactiveTemplatePersistenceStub) GetRaw(_ context.Context, _ string, _ string) (models.SObject, error) {
	if p.err != nil {
		return nil, p.err
	}
	active := !p.inactive
	return models.SObject{
		constants.FieldSysEmailTemplate_IsActive:  active,
		constants.FieldSysEmailTemplate_Subject:   "Subject",
		constants.FieldSysEmailTemplate_HTMLValue: "<p>Hi</p>",
		constants.FieldSysEmailTemplate_Body:      "Hi",
	}, nil
}
func (p *inactiveTemplatePersistenceStub) Insert(_ context.Context, _ string, _ models.SObject, _ *models.UserSession) (models.SObject, error) {
	panic("inactiveTemplatePersistenceStub.Insert: not implemented")
}
func (p *inactiveTemplatePersistenceStub) PreviewBeforeInsert(_ context.Context, _ string, _ models.SObject, _ *models.UserSession) (models.SObject, error) {
	panic("inactiveTemplatePersistenceStub.PreviewBeforeInsert: not implemented")
}
func (p *inactiveTemplatePersistenceStub) PrelockUpdateParents(_ context.Context, _ string, _ string, _ models.SObject, _ *models.UserSession) error {
	panic("inactiveTemplatePersistenceStub.PrelockUpdateParents: not implemented")
}
func (p *inactiveTemplatePersistenceStub) Update(_ context.Context, _ string, _ string, _ models.SObject, _ *models.UserSession) error {
	panic("inactiveTemplatePersistenceStub.Update: not implemented")
}
func (p *inactiveTemplatePersistenceStub) PreviewBeforeUpdate(_ context.Context, _ string, _ string, _ models.SObject, _ models.SObject, _ *models.UserSession) (*models.PreparedRecordUpdate, error) {
	panic("inactiveTemplatePersistenceStub.PreviewBeforeUpdate: not implemented")
}
func (p *inactiveTemplatePersistenceStub) CommitPreparedUpdate(_ context.Context, _ string, _ string, _ *models.PreparedRecordUpdate, _ models.SObject, _ *models.UserSession) error {
	panic("inactiveTemplatePersistenceStub.CommitPreparedUpdate: not implemented")
}
func (p *inactiveTemplatePersistenceStub) Delete(_ context.Context, _ string, _ string, _ *models.UserSession) error {
	panic("inactiveTemplatePersistenceStub.Delete: not implemented")
}
func (p *inactiveTemplatePersistenceStub) GetLock(_ context.Context, _ string, _ string) (models.SObject, error) {
	panic("inactiveTemplatePersistenceStub.GetLock: not implemented")
}
func (p *inactiveTemplatePersistenceStub) GetRecordType(_ context.Context, _ string) (string, error) {
	panic("inactiveTemplatePersistenceStub.GetRecordType: not implemented")
}
func (p *inactiveTemplatePersistenceStub) RunInTransaction(_ context.Context, fn func(context.Context) error) error {
	panic("inactiveTemplatePersistenceStub.RunInTransaction: not implemented")
}
func (p *inactiveTemplatePersistenceStub) IsTransactionActive(_ context.Context) bool { return false }
func (p *inactiveTemplatePersistenceStub) RunInPessimisticTransaction(_ context.Context, fn func(context.Context) error) error {
	panic("inactiveTemplatePersistenceStub.RunInPessimisticTransaction: not implemented")
}
func (p *inactiveTemplatePersistenceStub) DetachTx(ctx context.Context) context.Context { return ctx }
func (p *inactiveTemplatePersistenceStub) RecomputeActiveFormulaProjection(_ context.Context, _ *models.UserSession, _, _ string, _ []string) (models.SObject, error) {
	panic("inactiveTemplatePersistenceStub.RecomputeActiveFormulaProjection: not implemented")
}

// ── stepConfigFingerprint + resend fingerprint gate ──────────────────────────

// TestStepConfigFingerprint_BasicEquality verifies that the same config hashes
// the same, and a structurally different config (different url host) hashes
// differently.
func TestStepConfigFingerprint_BasicEquality(t *testing.T) {
	as := &ActionService{allowlist: url_safety.NewAllowlist()}
	cfg := map[string]interface{}{
		constants.ConfigURL:     "https://hooks.example.com/services/T/B/SECRET",
		constants.ConfigHeaders: map[string]interface{}{"Authorization": "Bearer TOKEN"},
	}
	fp1 := as.stepConfigFingerprint(cfg)
	fp2 := as.stepConfigFingerprint(cfg)
	if fp1 == "" {
		t.Fatal("fingerprint must not be empty")
	}
	if fp1 != fp2 {
		t.Errorf("same config produced different fingerprints: %q vs %q", fp1, fp2)
	}
	// Different host → different fingerprint.
	cfgDifferentHost := map[string]interface{}{
		constants.ConfigURL:     "https://other.example.com/services/T/B/SECRET",
		constants.ConfigHeaders: map[string]interface{}{"Authorization": "Bearer TOKEN"},
	}
	fp3 := as.stepConfigFingerprint(cfgDifferentHost)
	if fp3 == fp1 {
		t.Errorf("different host produced same fingerprint %q", fp1)
	}
}

// TestStepConfigFingerprint_AnyEditChanges verifies that ANY config edit —
// including rotating credential-bearing values (header value, url path/query)
// — changes the fingerprint: a step-addressed resend replays only against a
// byte-identical step config, so editing a step (even to rotate a secret)
// refuses replay of older attempts. Same-host path changes are exactly the
// "materially different endpoint" class the equality gate exists to refuse.
// Byte-identical content (regardless of map insertion order) hashes equal.
func TestStepConfigFingerprint_AnyEditChanges(t *testing.T) {
	as := &ActionService{allowlist: url_safety.NewAllowlist()}
	// Original step config.
	cfg1 := map[string]interface{}{
		constants.ConfigURL:     "https://api.example.com/services/T/B/OLD_SECRET",
		constants.ConfigHeaders: map[string]interface{}{"Authorization": "Bearer OLD_TOKEN"},
	}
	// Edited: same host, new path secret and new auth header value.
	cfg2 := map[string]interface{}{
		constants.ConfigURL:     "https://api.example.com/services/T/B/NEW_SECRET",
		constants.ConfigHeaders: map[string]interface{}{"Authorization": "Bearer NEW_TOKEN"},
	}
	fp1 := as.stepConfigFingerprint(cfg1)
	fp2 := as.stepConfigFingerprint(cfg2)
	if fp1 == "" || fp2 == "" {
		t.Fatal("fingerprints must not be empty")
	}
	if fp1 == fp2 {
		t.Errorf("edited step config produced the same fingerprint %q; any edit must change it", fp1)
	}
	// Same content, different map construction order → equal hash (canonical JSON).
	cfg1Again := map[string]interface{}{
		constants.ConfigHeaders: map[string]interface{}{"Authorization": "Bearer OLD_TOKEN"},
		constants.ConfigURL:     "https://api.example.com/services/T/B/OLD_SECRET",
	}
	if as.stepConfigFingerprint(cfg1Again) != fp1 {
		t.Error("identical config content must produce the same fingerprint regardless of map order")
	}
}

// TestStepConfigFingerprint_HeaderNameChangeIsStructural verifies that renaming
// a header (structural change) produces a different fingerprint.
func TestStepConfigFingerprint_HeaderNameChangeIsStructural(t *testing.T) {
	as := &ActionService{allowlist: url_safety.NewAllowlist()}
	cfg1 := map[string]interface{}{
		constants.ConfigURL:     "https://api.example.com/hook",
		constants.ConfigHeaders: map[string]interface{}{"X-Api-Key": "secret1"},
	}
	cfg2 := map[string]interface{}{
		constants.ConfigURL:     "https://api.example.com/hook",
		constants.ConfigHeaders: map[string]interface{}{"X-Signature": "secret2"},
	}
	fp1 := as.stepConfigFingerprint(cfg1)
	fp2 := as.stepConfigFingerprint(cfg2)
	if fp1 == fp2 {
		t.Errorf("header name change produced same fingerprint %q; structural changes must differ", fp1)
	}
}

// TestEnsureResendActionAuthority_StepFingerprintMismatchRefused verifies that
// a step-addressed resend where the frozen fingerprint no longer matches the
// current step config (structural change: different url host) is refused with a
// ValidationError mentioning "configuration changed".
func TestEnsureResendActionAuthority_StepFingerprintMismatchRefused(t *testing.T) {
	originalStepCfg := map[string]interface{}{
		constants.FieldID:             "step-email",
		constants.FieldSysAction_Type: constants.ActionTypeSendEmail,
		constants.ConfigEmailTemplate: "tmpl-1",
		constants.ConfigURL:           "https://original.example.com/hook",
	}
	// Current step has a different url host — structural change.
	currentStepCfg := map[string]interface{}{
		constants.FieldID:             "step-email",
		constants.FieldSysAction_Type: constants.ActionTypeSendEmail,
		constants.ConfigEmailTemplate: "tmpl-1",
		constants.ConfigURL:           "https://changed.example.com/hook",
	}
	as := &ActionService{
		allowlist: url_safety.NewAllowlist(),
		actionLookup: &stubActionByIDer{action: &models.ActionMetadata{
			ID:   "composite-1",
			Type: constants.ActionTypeComposite,
			Config: map[string]interface{}{
				constants.ConfigKeySteps: []interface{}{currentStepCfg},
			},
		}},
		persistence: &minimalEmailPersistenceStub{},
	}
	// Frozen fingerprint was computed from the ORIGINAL (now stale) config.
	frozenFP := as.stepConfigFingerprint(originalStepCfg)
	err := as.EnsureResendActionAuthority(context.Background(), "composite-1", "step-email", frozenFP)
	if err == nil {
		t.Fatal("expected error for changed step config, got nil")
	}
	var ve *apperrors.ValidationError
	if !errors.As(err, &ve) {
		t.Errorf("expected ValidationError, got %T: %v", err, err)
	}
	if !strings.Contains(err.Error(), "configuration changed") {
		t.Errorf("error = %q, want substring 'configuration changed'", err.Error())
	}
}

// TestEnsureResendActionAuthority_EmptyFingerprintOnStepAddressedRefused verifies
// that a step-addressed resend with an empty frozen fingerprint is refused.
func TestEnsureResendActionAuthority_EmptyFingerprintOnStepAddressedRefused(t *testing.T) {
	stepCfg := map[string]interface{}{
		constants.FieldID:             "step-email",
		constants.FieldSysAction_Type: constants.ActionTypeSendEmail,
		constants.ConfigEmailTemplate: "tmpl-1",
	}
	as := &ActionService{
		allowlist: url_safety.NewAllowlist(),
		actionLookup: &stubActionByIDer{action: &models.ActionMetadata{
			ID:   "composite-1",
			Type: constants.ActionTypeComposite,
			Config: map[string]interface{}{
				constants.ConfigKeySteps: []interface{}{stepCfg},
			},
		}},
		persistence: &minimalEmailPersistenceStub{},
	}
	// Pass empty fingerprint — delivery attempt carries no fingerprint.
	err := as.EnsureResendActionAuthority(context.Background(), "composite-1", "step-email", "")
	if err == nil {
		t.Fatal("expected error for empty fingerprint, got nil")
	}
	if !strings.Contains(err.Error(), "no step fingerprint") {
		t.Errorf("error = %q, want substring 'no step fingerprint'", err.Error())
	}
}

// TestResolveWebhookTarget_StepFingerprintMismatchRefused verifies that a
// step-addressed webhook resend where the frozen fingerprint no longer matches
// the current step config is refused.
func TestResolveWebhookTarget_StepFingerprintMismatchRefused(t *testing.T) {
	originalStepCfg := map[string]interface{}{
		constants.FieldID:             "step-wh",
		constants.FieldSysAction_Type: constants.ActionTypeCallWebhook,
		constants.ConfigURL:           "https://original.example.com/hook",
	}
	// Current step has a different url host — structural change.
	currentStepCfg := map[string]interface{}{
		constants.FieldID:             "step-wh",
		constants.FieldSysAction_Type: constants.ActionTypeCallWebhook,
		constants.ConfigURL:           "https://changed.example.com/hook",
	}
	as := &ActionService{
		formula:   formula.NewEngine(),
		allowlist: url_safety.NewAllowlist(),
		actionLookup: &stubActionByIDer{action: &models.ActionMetadata{
			ID:   "composite-1",
			Type: constants.ActionTypeComposite,
			Config: map[string]interface{}{
				constants.ConfigKeySteps: []interface{}{currentStepCfg},
			},
		}},
	}
	// Frozen fingerprint was computed from the ORIGINAL (now stale) config.
	frozenFP := as.stepConfigFingerprint(originalStepCfg)
	_, err := as.ResolveWebhookTarget(context.Background(), "composite-1", "step-wh", frozenFP)
	if err == nil {
		t.Fatal("expected error for changed step config, got nil")
	}
	var ve *apperrors.ValidationError
	if !errors.As(err, &ve) {
		t.Errorf("expected ValidationError, got %T: %v", err, err)
	}
	if !strings.Contains(err.Error(), "configuration changed") {
		t.Errorf("error = %q, want substring 'configuration changed'", err.Error())
	}
}

// TestResolveWebhookTarget_EmptyFingerprintOnStepAddressedRefused verifies that
// a step-addressed webhook resend with an empty frozen fingerprint is refused.
func TestResolveWebhookTarget_EmptyFingerprintOnStepAddressedRefused(t *testing.T) {
	stepCfg := map[string]interface{}{
		constants.FieldID:             "step-wh",
		constants.FieldSysAction_Type: constants.ActionTypeCallWebhook,
		constants.ConfigURL:           "https://hooks.example.com/hook",
	}
	as := &ActionService{
		formula:   formula.NewEngine(),
		allowlist: url_safety.NewAllowlist(),
		actionLookup: &stubActionByIDer{action: &models.ActionMetadata{
			ID:   "composite-1",
			Type: constants.ActionTypeComposite,
			Config: map[string]interface{}{
				constants.ConfigKeySteps: []interface{}{stepCfg},
			},
		}},
	}
	_, err := as.ResolveWebhookTarget(context.Background(), "composite-1", "step-wh", "")
	if err == nil {
		t.Fatal("expected error for empty fingerprint, got nil")
	}
	if !strings.Contains(err.Error(), "no step fingerprint") {
		t.Errorf("error = %q, want substring 'no step fingerprint'", err.Error())
	}
}

// TestResolveWebhookTarget_TopLevelFingerprintMismatchRefused verifies that a
// TOP-LEVEL webhook resend where the frozen fingerprint no longer matches the
// action's current config is refused: the action row is mutable, and an admin
// retarget must never route a frozen historical payload to the new endpoint.
func TestResolveWebhookTarget_TopLevelFingerprintMismatchRefused(t *testing.T) {
	originalCfg := map[string]interface{}{
		constants.ConfigURL: "https://original.example.com/hook",
		"method":            "POST",
	}
	as := &ActionService{
		formula:   formula.NewEngine(),
		allowlist: url_safety.NewAllowlist(),
		actionLookup: &stubActionByIDer{action: &models.ActionMetadata{
			ID:   "action-retargeted",
			Type: constants.ActionTypeCallWebhook,
			Config: map[string]interface{}{
				constants.ConfigURL: "https://changed.example.com/hook",
				"method":            "POST",
			},
		}},
	}
	frozenFP := as.stepConfigFingerprint(originalCfg)
	_, err := as.ResolveWebhookTarget(context.Background(), "action-retargeted", "", frozenFP)
	if err == nil {
		t.Fatal("expected error for retargeted top-level config, got nil")
	}
	var ve *apperrors.ValidationError
	if !errors.As(err, &ve) {
		t.Errorf("expected ValidationError, got %T: %v", err, err)
	}
	if !strings.Contains(err.Error(), "configuration changed") {
		t.Errorf("error = %q, want substring 'configuration changed'", err.Error())
	}
}

// TestResolveWebhookTarget_TopLevelEmptyFingerprintRefused verifies that a
// top-level webhook resend with an empty frozen fingerprint fails closed.
func TestResolveWebhookTarget_TopLevelEmptyFingerprintRefused(t *testing.T) {
	as := &ActionService{
		formula:   formula.NewEngine(),
		allowlist: url_safety.NewAllowlist(),
		actionLookup: &stubActionByIDer{action: &models.ActionMetadata{
			ID:   "action-no-fp",
			Type: constants.ActionTypeCallWebhook,
			Config: map[string]interface{}{
				constants.ConfigURL: "https://hooks.example.com/hook",
				"method":            "POST",
			},
		}},
	}
	_, err := as.ResolveWebhookTarget(context.Background(), "action-no-fp", "", "")
	if err == nil {
		t.Fatal("expected error for empty top-level fingerprint, got nil")
	}
	if !strings.Contains(err.Error(), "no config fingerprint") {
		t.Errorf("error = %q, want substring 'no config fingerprint'", err.Error())
	}
}

// TestCompositeWebhookAttempt_StoresStepFingerprint verifies that when a
// callWebhook step executes inside a composite action, the attempt row's
// WebhookPayload.StepFingerprint is non-empty (mirrors StoresRootActionIDAndStepID).
func TestCompositeWebhookAttempt_StoresStepFingerprint(t *testing.T) {
	allowlist := url_safety.NewAllowlist()
	recorder := &fakeAttemptRecorder{}
	client := &stubHTTPClientForHook{statusCode: 200}
	txm := &recordingActionTxManager{}
	as := &ActionService{
		formula:         formula.NewEngine(),
		webhookClient:   client,
		allowlist:       allowlist,
		logger:          logging.New(context.Background()),
		attemptRecorder: recorder,
		txManager:       txm,
	}

	compositeAction := &models.ActionMetadata{
		ID:            "composite-root-1",
		ObjectAPIName: "Lead",
		Type:          constants.ActionTypeComposite,
		Config: map[string]interface{}{
			constants.ConfigKeySteps: []interface{}{
				map[string]interface{}{
					constants.FieldID:             "step-wh",
					constants.FieldSysAction_Type: constants.ActionTypeCallWebhook,
					constants.ConfigURL:           "https://example.com/hook",
					constants.ConfigMethod:        "POST",
					constants.ConfigHeaders: map[string]interface{}{
						"X-Custom": "LIVE_HEADER_VALUE",
					},
				},
			},
		},
	}

	actionCtx := &ActionContext{
		Record:  models.SObject{"Id": "lead-1"},
		User:    &models.UserSession{ID: "005-user"},
		Results: make(map[string]map[string]interface{}),
	}

	err := as.executeComposite(context.Background(), compositeAction, actionCtx)
	if err != nil {
		t.Fatalf("executeComposite: %v", err)
	}
	if len(recorder.calls) != 1 {
		t.Fatalf("expected 1 attempt, got %d", len(recorder.calls))
	}
	got := recorder.calls[0]
	if got.WebhookPayload == nil {
		t.Fatal("WebhookPayload is nil")
	}
	if got.WebhookPayload.StepFingerprint == "" {
		t.Error("WebhookPayload.StepFingerprint must be non-empty for composite step")
	}
}

// TestCompositeEmailAttempt_StoresStepFingerprint verifies that when a sendEmail
// step executes inside a composite action, the attempt row's
// EmailPayload.StepFingerprint is non-empty.
func TestCompositeEmailAttempt_StoresStepFingerprint(t *testing.T) {
	recorder := &fakeAttemptRecorder{}
	sender := &stubEmailSenderForHook{}
	as := &ActionService{
		formula:         formula.NewEngine(),
		allowlist:       url_safety.NewAllowlist(),
		emailSender:     sender,
		persistence:     &minimalEmailPersistenceStub{},
		attemptRecorder: recorder,
		logger:          logging.New(context.Background()),
	}
	// Drive executeCanonicalSendEmail directly with a pre-computed StepFingerprint
	// (mirrors TestCompositeEmailAttempt_FreezesStepID but checks StepFingerprint).
	stepCfg := map[string]interface{}{
		constants.FieldID:             "step-email-1",
		constants.FieldSysAction_Type: constants.ActionTypeSendEmail,
		constants.ConfigEmailTemplate: "tmpl-001",
		constants.ConfigRecipient:     "ops@example.com",
	}
	fp := as.stepConfigFingerprint(stepCfg)
	req := canonicalSendEmailRequest{
		TemplateID:      "tmpl-001",
		To:              []string{"ops@example.com"},
		ObjectAPIName:   "Lead",
		ActionID:        "composite-root-1",
		StepID:          "step-email-1",
		StepFingerprint: fp,
	}
	_, err := as.executeCanonicalSendEmail(context.Background(), req)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(recorder.calls) != 1 {
		t.Fatalf("expected 1 attempt row, got %d", len(recorder.calls))
	}
	call := recorder.calls[0]
	if call.EmailPayload == nil {
		t.Fatal("EmailPayload is nil")
	}
	if call.EmailPayload.StepFingerprint == "" {
		t.Errorf("EmailPayload.StepFingerprint must be non-empty for composite step; got empty")
	}
	if call.EmailPayload.StepFingerprint != fp {
		t.Errorf("StepFingerprint = %q, want %q", call.EmailPayload.StepFingerprint, fp)
	}
}
