package automation

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"

	"github.com/nexuscrm/kernel/pkg/emailaddress"
	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/metadata"
	"github.com/nexuscrm/outbound/externaldelivery"
	"github.com/nexuscrm/permissions"

	apperrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/formula"
	"github.com/nexuscrm/kernel/pkg/logging"
	"github.com/nexuscrm/kernel/pkg/utils"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

// attemptRecorderIface is the narrow interface ActionService uses for
// recording delivery attempts. An *externaldelivery.Store satisfies it in
// production; nil means no-op (all existing unit tests compile unchanged).
type attemptRecorderIface interface {
	RecordAttempt(ctx context.Context, in externaldelivery.AttemptWrite) (string, error)
}

// actionByIDer is the narrow interface ActionService uses to look up a LIVE
// action by ID for operator-resend authority (soft-deleted/missing → (nil,nil);
// DB failure → (nil,err)). *metadata.MetadataService satisfies it via
// GetLiveActionByID; a stub satisfies it in unit tests.
type actionByIDer interface {
	GetLiveActionByID(ctx context.Context, actionID string) (*models.ActionMetadata, error)
}

// ActionService handles execution of metadata-driven actions
type ActionService struct {
	metadata        *metadata.MetadataService
	persistence     PersistenceProvider
	permissions     fieldVisibilityChecker
	txManager       ports.TransactionManager
	emailSender     ports.EmailSender
	webhookClient   ports.HTTPClient
	allowlist       ports.URLAllowlist
	formula         *formula.Engine
	logger          *logging.Logger
	attemptRecorder attemptRecorderIface // optional; nil → no-op
	// actionLookup is an optional override used in unit tests; nil → falls back
	// to as.metadata. Satisfies actionByIDer.
	actionLookup actionByIDer
	// emailTransportNotDelivering marks that the injected EmailSender accepts
	// messages without delivering them (noop transport: SMTP not configured).
	// Evidence rows must tell the truth: attempts under a non-delivering
	// transport are recorded as failed with an explicit reason, and operator
	// resend refuses with the same reason instead of reporting false success.
	// Flow/funnel send behavior is unchanged — only evidence + resend signal.
	emailTransportNotDelivering bool
}

var errCompositeActionNeedsPessimisticBoundary = apperrors.NewInternalError(
	"composite action requires an owned rollback boundary; nested reuse is allowed only inside an existing pessimistic transaction",
	nil,
)

// errEmailTransportNotDelivering is recorded as the attempt error message when
// the email transport is the noop sender (SMTP_HOST unset). The message was
// accepted and logged by the noop sender but NOT delivered to any recipient.
var errEmailTransportNotDelivering = apperrors.NewValidationError(
	"emailTransport",
	"email transport is not configured (SMTP_HOST unset): the message was accepted by the noop sender and NOT delivered",
)

type canonicalSendEmailRequest struct {
	TemplateID    string
	To            []string
	Cc            []string
	SenderPolicy  *canonicalSendEmailSenderPolicy
	Variables     map[string]interface{}
	Record        models.SObject
	User          *models.UserSession
	ObjectAPIName string
	// ActionID is threaded from the calling action so that SourceActionId is
	// populated in the attempt evidence row (C3: action id must not be empty).
	ActionID string
	// StepID addresses the sendEmail step inside a composite action's config
	// for operator resend — frozen symmetrically with the webhook path.
	// Empty for top-level sendEmail actions and flow-emitted email.
	StepID string
	// StepFingerprint is the storage-safe fingerprint (sha256 hex) of the
	// authored step's config, frozen at send time for step-addressed resend.
	// Empty for top-level sendEmail actions and flow-emitted email.
	StepFingerprint string
}

type canonicalSendEmailSenderPolicy struct {
	Type string
}

const (
	canonicalSendEmailSenderDefault = "default"
)

// NewActionService creates a new ActionService.
// webhookClient must be non-nil; pass infrastructure/http_client.NewWebhookClient(allowlist)
// in production. It is used by executeCallWebhook for all outbound webhook requests
// (url_safety preflight, proxy-aware transport, cred-aware redirect checks, 30s Timeout).
// allowlist must be non-nil; pass url_safety.NewAllowlist() in production. It is used
// for URL policy preflight and log-safe URL redaction in executeCallWebhook.
func NewActionService(metadata *metadata.MetadataService, persistence PersistenceProvider, permissions *permissions.PermissionService, txManager ports.TransactionManager, emailSender ports.EmailSender, webhookClient ports.HTTPClient, allowlist ports.URLAllowlist, logger *logging.Logger) *ActionService {
	return &ActionService{
		metadata:      metadata,
		persistence:   persistence,
		permissions:   permissions,
		txManager:     txManager,
		emailSender:   emailSender,
		webhookClient: webhookClient,
		allowlist:     allowlist,
		formula:       formula.NewEngine(),
		logger:        logger,
	}
}

// SetAttemptRecorder injects the optional delivery-attempt recorder.
// Call this after NewActionService in production (service_manager_init).
// Existing call sites that do not call this continue to compile and run
// unchanged; the recorder is nil → no-op (C8).
func (as *ActionService) SetAttemptRecorder(r attemptRecorderIface) {
	as.attemptRecorder = r
}

// SetEmailTransportNotDelivering marks the email transport as accept-only
// (noop). Called from service wiring when the env-selected sender is the
// noop sender (SMTP_HOST unset). Evidence rows record failed with an explicit
// reason; operator resend returns the sentinel error instead of false success.
func (as *ActionService) SetEmailTransportNotDelivering(notDelivering bool) {
	as.emailTransportNotDelivering = notDelivering
}

// ActionContext holds the state of an action execution, including results from previous steps
type ActionContext struct {
	Record    models.SObject
	User      *models.UserSession
	Variables map[string]interface{}
	Results   map[string]map[string]interface{}
	// CompositeRootActionID is the persisted _System_Action row id of the
	// outermost composite action currently executing, set first-setter-wins by
	// executeComposite. Delivery-attempt evidence must reference a persisted,
	// resend-resolvable action row, so recorder hooks store this root id (plus
	// the local step id as payload addressing) instead of a synthesized inline
	// step id. Empty outside composite execution.
	CompositeRootActionID string
}

type fieldVisibilityChecker interface {
	CheckFieldVisibilityWithUser(ctx context.Context, objectAPIName, fieldAPIName string, user *models.UserSession) (bool, error)
}

// ExecuteAction executes an action by ID with the given context record and user
func (as *ActionService) ExecuteAction(ctx context.Context, actionID string, contextRecord models.SObject, user *models.UserSession) error {
	// Find the action in metadata cache
	action, err := as.findAction(ctx, actionID)
	if err != nil {
		return apperrors.NewInternalError("failed to resolve action", err)
	}
	if action == nil {
		return apperrors.NewNotFoundError("action", actionID)
	}

	// Create context
	actionCtx := &ActionContext{
		Record:    contextRecord,
		User:      user,
		Variables: make(map[string]interface{}),
		Results:   make(map[string]map[string]interface{}),
	}

	return as.executeActionFromMetadata(ctx, action, actionCtx)
}

// executeActionFromMetadata executes an action definition with the given context
func (as *ActionService) executeActionFromMetadata(ctx context.Context, action *models.ActionMetadata, actionCtx *ActionContext) error {
	// Execute based on action type
	switch action.Type {
	case constants.ActionTypeCreateRecord:
		return as.executeCreateRecord(ctx, action, actionCtx)
	case constants.ActionTypeUpdateRecord:
		return as.executeUpdateRecord(ctx, action, actionCtx)
	case constants.ActionTypeSendEmail:
		return as.executeSendEmail(ctx, action, actionCtx)
	case constants.ActionTypeCallWebhook:
		return as.executeCallWebhook(ctx, action, actionCtx)
	case constants.ActionTypeComposite:
		return as.executeComposite(ctx, action, actionCtx)
	default:
		return apperrors.NewValidationError("action.Type", fmt.Sprintf("unsupported action type: %s", action.Type))
	}
}

// ExecuteActionDirect executes an action metadata directly without ID lookup (used by FlowExecutor)
func (as *ActionService) ExecuteActionDirect(ctx context.Context, action *models.ActionMetadata, record models.SObject, user *models.UserSession) error {
	return as.ExecuteActionDirectWithVariables(ctx, action, record, nil, user)
}

// ExecuteActionDirectWithVariables executes an action with explicit caller-supplied
// variables for native EmailTemplate rendering.
func (as *ActionService) ExecuteActionDirectWithVariables(ctx context.Context, action *models.ActionMetadata, record models.SObject, variables map[string]interface{}, user *models.UserSession) error {
	actionCtx := &ActionContext{
		Record:    record,
		User:      user,
		Variables: cloneActionVariables(variables),
		Results:   make(map[string]map[string]interface{}),
	}
	return as.executeActionFromMetadata(ctx, action, actionCtx)
}

// findAction resolves an action by ID for EXECUTION through the live lookup
// (COALESCE(IsDeleted, 0) = 0): a soft-deleted action must not execute, while
// legacy warm-upgraded NULL rows stay executable — the same liveness semantics
// as resend authority and the action list reads. A read error is propagated
// (internal fault), not collapsed into "action not found".
func (as *ActionService) findAction(ctx context.Context, actionID string) (*models.ActionMetadata, error) {
	return as.metadata.GetLiveActionByID(ctx, actionID)
}

// executeCreateRecord creates a new record based on action configuration
func (as *ActionService) executeCreateRecord(ctx context.Context, action *models.ActionMetadata, actionCtx *ActionContext) error {
	// Extract configuration
	targetObject, err := utils.GetConfigStringRequired(action.Config, constants.ConfigTargetObject)
	if err != nil {
		return err
	}

	fieldMappings, ok := utils.GetConfigMap(action.Config, constants.ConfigFieldMappings)
	if !ok {
		return apperrors.NewValidationError("field_mappings", "field_mappings not specified in action config")
	}

	// Build record from field mappings
	record := make(models.SObject)
	for fieldName, formulaOrValue := range fieldMappings {
		value, err := as.evaluateRef(ctx, formulaOrValue, actionCtx, action.ObjectAPIName)
		if err != nil {
			return apperrors.NewInternalError(fmt.Sprintf("failed to evaluate field %s", fieldName), err)
		}
		record[fieldName] = value
	}

	// Insert the record (context propagates the transaction)
	result, err := as.persistence.Insert(ctx, targetObject, record, actionCtx.User)
	if err != nil {
		return flowRecordMutationValidation(err)
	}

	// Store result for future steps
	actionCtx.Results[action.ID] = result

	return nil
}

// executeUpdateRecord updates an existing record based on action configuration
func (as *ActionService) executeUpdateRecord(ctx context.Context, action *models.ActionMetadata, actionCtx *ActionContext) error {
	// Extract configuration
	targetObjectName, err := utils.GetConfigStringRequired(action.Config, constants.ConfigTargetObject)
	if err != nil {
		return err
	}

	recordIDVal, err := as.getConfigValue(ctx, action.Config, constants.ConfigRecordID, actionCtx, action.ObjectAPIName)
	if err != nil {
		return apperrors.NewInternalError("failed to get record_id", err)
	}
	recordID := fmt.Sprintf("%v", recordIDVal)

	fieldMappings, ok := utils.GetConfigMap(action.Config, constants.ConfigFieldMappings)
	if !ok {
		return apperrors.NewValidationError("field_mappings", "field_mappings not specified in action config")
	}

	// Build updates from field mappings
	updates := make(models.SObject)
	for fieldName, formulaOrValue := range fieldMappings {
		value, err := as.evaluateRef(ctx, formulaOrValue, actionCtx, action.ObjectAPIName)
		if err != nil {
			return apperrors.NewInternalError(fmt.Sprintf("failed to evaluate field %s", fieldName), err)
		}
		updates[fieldName] = value
	}

	// Update the record
	return flowRecordMutationValidation(
		as.persistence.Update(ctx, targetObjectName, recordID, updates, actionCtx.User),
	)
}

// executeComposite executes a sequence of actions within a transaction
func (as *ActionService) executeComposite(ctx context.Context, action *models.ActionMetadata, actionCtx *ActionContext) error {
	// First-setter-wins: record the outermost composite's persisted action id so
	// recorder hooks (webhook, email) can store a resend-resolvable action row id
	// instead of a synthesized inline step id. Nested composites are themselves
	// synthesized steps; their ID is local, so we keep the root id already set.
	if actionCtx.CompositeRootActionID == "" {
		actionCtx.CompositeRootActionID = action.ID
	}

	// composite action config should have a "steps" array
	stepsInterface, ok := action.Config[constants.ConfigKeySteps]
	if !ok {
		return apperrors.NewValidationError("steps", "steps not specified in composite action")
	}

	stepsList, ok := stepsInterface.([]interface{})
	if !ok {
		return apperrors.NewValidationError("steps", "steps must be an array")
	}

	// Nested reuse is safe only when the caller already owns a pessimistic
	// rollback boundary. A plain active transaction is not safe for nested
	// generic record CRUD that can now route through rollup-aware locking.
	if as.txManager != nil && as.txManager.IsTransactionActive(ctx) {
		if !as.txManager.IsPessimisticTx(ctx) {
			return errCompositeActionNeedsPessimisticBoundary
		}
		return as.executeSteps(ctx, stepsList, actionCtx, action.ObjectAPIName)
	}
	if as.txManager == nil {
		return apperrors.NewInternalError("composite action transaction manager is not configured", nil)
	}

	// EXECUTE WITHIN TRANSACTION
	return as.txManager.WithPessimisticTransaction(ctx, func(txCtx context.Context) error {
		// Execute steps using the transactional context
		return as.executeSteps(txCtx, stepsList, actionCtx, action.ObjectAPIName)
	})
}

func (as *ActionService) executeSteps(ctx context.Context, steps []interface{}, actionCtx *ActionContext, sourceObjectName string) error {
	for _, stepInterface := range steps {
		stepConfig, ok := stepInterface.(map[string]interface{})
		if !ok {
			return apperrors.NewValidationError("step", "invalid step config format")
		}

		stepID, _ := stepConfig[constants.FieldID].(string)
		stepType, _ := stepConfig[constants.FieldSysAction_Type].(string)

		if stepID == "" || stepType == "" {
			return apperrors.NewValidationError("step", "step must have id and type")
		}

		stepAction := &models.ActionMetadata{
			ID:            stepID,
			ObjectAPIName: sourceObjectName, // Inherit source object context
			Type:          stepType,
			Config:        stepConfig, // Pass full config
		}

		// Recursively execute
		if err := as.executeActionFromMetadata(ctx, stepAction, actionCtx); err != nil {
			// Preserve genuine record-mutation validation (and the legacy
			// validation-rule contract) across the Composite step wrapper. Other
			// action/configuration failures remain InternalError.
			if _, ok := flowClientValidationForPresentation(err); ok {
				return err
			}
			return apperrors.NewInternalError(fmt.Sprintf("step %s failed", stepID), err)
		}
	}
	return nil
}

// executeSendEmail dispatches a metadata-backed SendEmail action — a _System_Action
// row with Type=SendEmail reached via ExecuteActionDirect, the generic Flow
// action-call fallback, or a Composite step. It resolves the action's config
// (emailTemplate/recipient) and routes through executeCanonicalSendEmail, the single
// send implementation, so metadata-backed, Flow-native sendEmail, and emailSimple
// all share ONE runtime read path. This entry adapts to the canonical request; the
// actual send lives only in executeCanonicalSendEmail.
func (as *ActionService) executeSendEmail(ctx context.Context, action *models.ActionMetadata, actionCtx *ActionContext) error {
	templateIDRaw, err := as.getConfigValue(ctx, action.Config, constants.ConfigEmailTemplate, actionCtx, action.ObjectAPIName)
	if err != nil {
		return apperrors.NewInternalError("failed to get emailTemplate from action config", err)
	}
	recipientRaw, err := as.getConfigValue(ctx, action.Config, constants.ConfigRecipient, actionCtx, action.ObjectAPIName)
	if err != nil {
		return apperrors.NewInternalError("failed to get recipient from action config", err)
	}
	toEmail, err := validateCanonicalSendEmailAddress(constants.ConfigRecipient, fmt.Sprintf("%v", recipientRaw))
	if err != nil {
		return err
	}
	// Evidence ActionID rule: a persisted _System_Action row id whenever an
	// automation action authored the send (composite steps record the ROOT id,
	// never a synthesized local id); EMPTY only for Flow-emitted email, which has
	// no authoring persisted action (FlowActionCall has no stable action id —
	// documented limitation). This path is reached via ExecuteActionDirect /
	// Composite steps; substitute the outermost composite's persisted id when
	// running inside a composite.
	// StepID addresses this sendEmail step inside the composite's config for
	// operator resend — frozen symmetrically with the webhook path.
	emailActionID := action.ID
	emailStepID := ""
	emailStepFingerprint := ""
	if actionCtx != nil && actionCtx.CompositeRootActionID != "" && actionCtx.CompositeRootActionID != action.ID {
		emailActionID = actionCtx.CompositeRootActionID
		emailStepID = action.ID
		emailStepFingerprint = as.stepConfigFingerprint(action.Config)
	}
	req := canonicalSendEmailRequest{
		TemplateID:      fmt.Sprintf("%v", templateIDRaw),
		To:              []string{toEmail},
		ObjectAPIName:   action.ObjectAPIName,
		ActionID:        emailActionID,        // C3: thread action id so SourceActionId is populated
		StepID:          emailStepID,          // frozen composite-step address for operator resend
		StepFingerprint: emailStepFingerprint, // frozen storage-safe fingerprint for resend identity
	}
	if actionCtx != nil {
		req.Record = actionCtx.Record
		req.User = actionCtx.User
		req.Variables = actionCtx.Variables
	}
	_, err = as.executeCanonicalSendEmail(ctx, req)
	return err
}

func (as *ActionService) executeCanonicalSendEmail(ctx context.Context, req canonicalSendEmailRequest) (*ports.EmailSendResult, error) {
	templateID := strings.TrimSpace(req.TemplateID)
	if templateID == "" {
		return nil, apperrors.NewValidationError("templateId", "templateId is required for sendEmail")
	}
	if len(req.To) == 0 {
		return nil, apperrors.NewValidationError("to", "sendEmail requires at least one to recipient")
	}
	if as == nil || as.emailSender == nil {
		return nil, apperrors.NewInternalError("email sender is not configured", nil)
	}
	from, senderPolicyType, err := resolveCanonicalSendEmailFrom(req)
	if err != nil {
		return nil, err
	}

	actionCtx := &ActionContext{
		Record:    req.Record,
		User:      req.User,
		Variables: cloneActionVariables(req.Variables),
		Results:   make(map[string]map[string]interface{}),
	}
	subject, htmlBody, plainBody, err := as.LoadAndRenderTemplate(ctx, templateID, actionCtx, req.ObjectAPIName)
	if err != nil {
		return nil, err
	}

	// Freeze the rendered message before passing to Send so the attempt records
	// the canonical CONTENT. Resend contract: content replay + current identity —
	// for the default sender policy From is "" and the transport injects the
	// CURRENT effective sender on original send and resend alike (same rule as
	// webhook header re-resolution). #2144 tracks recording the effective sender
	// as observability.
	frozenMsg := &ports.EmailMessage{
		To:      externaldelivery.CopyStringSlice(req.To),
		Cc:      externaldelivery.CopyStringSlice(req.Cc),
		From:    from,
		Subject: subject,
		Body:    plainBody,
		HTML:    htmlBody,
	}

	result, sendErr := as.emailSender.Send(ctx, frozenMsg)

	// Record the attempt on both success and failure (C6: swallow record-write
	// failure after a successful send — do NOT re-send).
	if as.attemptRecorder != nil {
		sourceRecordID := ""
		if req.Record != nil {
			if v, ok := req.Record[constants.FieldID]; ok {
				sourceRecordID = fmt.Sprintf("%v", v)
			}
		}
		createdByID := ""
		if req.User != nil {
			createdByID = req.User.ID
		}
		attemptStatus := externaldelivery.StatusSuccess
		errMsg := ""
		if sendErr != nil {
			attemptStatus = externaldelivery.StatusFailed
			// Persist the SAME sanitized failure shape this funnel returns to its
			// caller below: provider transport errors can embed the rendered
			// recipients/subject/body, and ErrorMessage is exposed by the admin
			// list/detail API — raw provider text must never reach the row.
			errMsg = externaldelivery.TruncateErrorMessage(canonicalSendEmailProviderError(len(req.To) + len(req.Cc)))
		} else if as.emailTransportNotDelivering {
			// The noop sender returned success, but no message was actually
			// delivered. Record the attempt as failed with an explicit reason so
			// operators see accurate evidence. Flow execution still returns the
			// result/nil error below — funnel behavior is unchanged.
			attemptStatus = externaldelivery.StatusFailed
			errMsg = externaldelivery.TruncateErrorMessage(errEmailTransportNotDelivering)
		}
		targetSummary := externaldelivery.TargetSummaryForEmail(frozenMsg.To, frozenMsg.Cc)
		_, recordErr := as.attemptRecorder.RecordAttempt(ctx, externaldelivery.AttemptWrite{
			Channel:         externaldelivery.ChannelEmail,
			Status:          attemptStatus,
			SourceObjectAPI: req.ObjectAPIName,
			SourceRecordID:  sourceRecordID,
			ActionID:        req.ActionID,
			TargetSummary:   targetSummary,
			EmailPayload: &externaldelivery.EmailPayload{
				To:              externaldelivery.CopyStringSlice(frozenMsg.To),
				Cc:              externaldelivery.CopyStringSlice(frozenMsg.Cc),
				Bcc:             externaldelivery.CopyStringSlice(frozenMsg.Bcc),
				From:            frozenMsg.From,
				Subject:         frozenMsg.Subject,
				Body:            frozenMsg.Body,
				HTML:            frozenMsg.HTML,
				StepID:          req.StepID,
				StepFingerprint: req.StepFingerprint,
			},
			ErrorMessage: errMsg,
			CreatedByID:  createdByID,
		})
		if recordErr != nil {
			// Always log a record-write failure — even when the send also failed —
			// so a double-failure (delivery down + DB down) leaves an operator trace
			// instead of silently dropping the attempt row. C6: never re-send.
			// Log the SANITIZED errMsg, never the raw provider error: provider
			// transport errors can embed rendered recipient/subject/body content.
			if as.logger != nil {
				as.logger.Info(fmt.Sprintf("FLOW SEND EMAIL: attempt record write failed (sendErrSanitized=%q): %v", errMsg, recordErr))
			}
		}
	}

	if sendErr != nil {
		return nil, canonicalSendEmailProviderError(len(req.To) + len(req.Cc))
	}
	if result == nil {
		result = &ports.EmailSendResult{}
	}
	if as.logger != nil {
		as.logger.Info(fmt.Sprintf("FLOW SEND EMAIL: Template=%s ToCount=%d CcCount=%d SenderPolicy=%s FromOverride=%t SentCount=%d RecipientResultCount=%d", templateID, len(req.To), len(req.Cc), senderPolicyType, from != "", result.SentCount, len(result.RecipientResults)))
	}
	return result, nil
}

func resolveCanonicalSendEmailFrom(req canonicalSendEmailRequest) (string, string, error) {
	policyType := canonicalSendEmailSenderDefault
	if req.SenderPolicy != nil {
		policyType = strings.TrimSpace(req.SenderPolicy.Type)
	}
	switch normalizeCanonicalSendEmailSenderPolicyType(policyType) {
	case canonicalSendEmailSenderDefault:
		return "", canonicalSendEmailSenderDefault, nil
	default:
		return "", policyType, apperrors.NewValidationError("from", fmt.Sprintf("unsupported sendEmail sender policy %q; only default is supported until a generic sender-identity source of truth exists", policyType))
	}
}

func normalizeCanonicalSendEmailSenderPolicyType(policyType string) string {
	key := strings.ToLower(strings.TrimSpace(policyType))
	key = strings.ReplaceAll(key, "_", "")
	key = strings.ReplaceAll(key, "-", "")
	key = strings.ReplaceAll(key, " ", "")
	switch key {
	case "", "default":
		return canonicalSendEmailSenderDefault
	default:
		return strings.TrimSpace(policyType)
	}
}

func canonicalSendEmailProviderError(recipientCount int) error {
	return apperrors.NewInternalError(fmt.Sprintf("failed to send email to %d recipients", recipientCount), nil)
}

func validateCanonicalSendEmailAddress(fieldName, address string) (string, error) {
	if strings.TrimSpace(address) == "" {
		return "", apperrors.NewValidationError(fieldName, fmt.Sprintf("%s recipient is empty", fieldName))
	}
	address, ok := emailaddress.ValidateCanonicalSendEmailAddress(address)
	if !ok {
		return "", apperrors.NewValidationError(fieldName, fmt.Sprintf("%s must be an email address string", fieldName))
	}
	return address, nil
}

// emailTemplateIsActive returns true when the _System_EmailTemplate row's
// IsActive field is truthy. The field can be stored as bool, int64, or float64
// depending on the DB driver and query path, so we coerce all three. A missing
// field is treated as inactive (fail-closed). This helper is shared by
// LoadAndRenderTemplate (send-time gate) and EnsureResendActionAuthority
// (resend-time gate) so both gates cannot drift in their coercion logic.
func emailTemplateIsActive(tpl map[string]interface{}) bool {
	isActive, ok := tpl[constants.FieldSysEmailTemplate_IsActive]
	if !ok {
		return false
	}
	switch v := isActive.(type) {
	case bool:
		return v
	case int64:
		return v == 1
	case float64:
		return v == 1
	default:
		return false
	}
}

// LoadAndRenderTemplate loads an email template by ID, validates it is active,
// and renders native merge fields in subject/body. Runtime rendering is
// fail-closed: unresolved or non-native tokens return an error before send.
func (as *ActionService) LoadAndRenderTemplate(ctx context.Context, templateID string, actionCtx *ActionContext, objectAPIName string) (subject, htmlBody, plainBody string, err error) {
	template, lookupErr := as.persistence.GetRaw(ctx, constants.TableEmailTemplate, templateID)
	if lookupErr != nil {
		return "", "", "", apperrors.NewInternalError(fmt.Sprintf("failed to look up email template %s", templateID), lookupErr)
	}
	if template == nil {
		return "", "", "", apperrors.NewNotFoundError("EmailTemplate", templateID)
	}

	// Validate template is active — use the shared helper so EnsureResendActionAuthority
	// applies the same coercion and cannot drift.
	if !emailTemplateIsActive(template) {
		return "", "", "", apperrors.NewValidationError("emailTemplate", fmt.Sprintf("email template %s is inactive", templateID))
	}

	subject = utils.GetConfigString(template, constants.FieldSysEmailTemplate_Subject)
	htmlBody = utils.GetConfigString(template, constants.FieldSysEmailTemplate_HTMLValue)
	plainBody = utils.GetConfigString(template, constants.FieldSysEmailTemplate_Body)

	renderCtx := as.nativeEmailTemplateContext(ctx, actionCtx, objectAPIName)
	if subject, err = renderNativeEmailTemplateField("Subject", subject, renderCtx); err != nil {
		return "", "", "", err
	}
	if htmlBody, err = renderNativeEmailTemplateField("HtmlValue", htmlBody, renderCtx); err != nil {
		return "", "", "", err
	}
	if plainBody, err = renderNativeEmailTemplateField("Body", plainBody, renderCtx); err != nil {
		return "", "", "", err
	}

	return subject, htmlBody, plainBody, nil
}

func cloneActionVariables(variables map[string]interface{}) map[string]interface{} {
	cloned := make(map[string]interface{}, len(variables))
	for key, value := range variables {
		cloned[key] = value
	}
	return cloned
}

func (as *ActionService) nativeEmailTemplateContext(ctx context.Context, actionCtx *ActionContext, objectAPIName string) nativeEmailTemplateContext {
	renderCtx := nativeEmailTemplateContextFromAction(actionCtx)
	if as == nil || as.permissions == nil || actionCtx == nil || actionCtx.User == nil {
		return renderCtx
	}
	renderCtx.checkRecordFieldVisible = func(fieldAPIName string) (bool, error) {
		if objectAPIName == "" {
			return false, nil
		}
		return as.permissions.CheckFieldVisibilityWithUser(ctx, objectAPIName, fieldAPIName, actionCtx.User)
	}
	return renderCtx
}

// executeCallWebhook calls a webhook based on action configuration
func (as *ActionService) executeCallWebhook(ctx context.Context, action *models.ActionMetadata, actionCtx *ActionContext) error {
	// Extract webhook configuration
	urlValue, err := as.getConfigValue(ctx, action.Config, constants.ConfigURL, actionCtx, action.ObjectAPIName)
	if err != nil {
		return apperrors.NewInternalError("failed to get webhook URL", err)
	}
	webhookURL := fmt.Sprintf("%v", urlValue)

	methodValue, err := as.getConfigValue(ctx, action.Config, constants.ConfigMethod, actionCtx, action.ObjectAPIName)
	if err != nil {
		// Default to POST if not specified
		methodValue = "POST"
	}
	method := strings.ToUpper(fmt.Sprintf("%v", methodValue))

	// Validate method
	if method != "GET" && method != "POST" && method != "PUT" && method != "PATCH" && method != "DELETE" {
		return apperrors.NewValidationError("method", fmt.Sprintf("invalid HTTP method: %s", method))
	}

	// Build request body from payload config. C2: capture payloadBytes BEFORE
	// wrapping in a reader so the frozen WebhookPayload.Body has the real body.
	var payloadBytes []byte
	if payload, err := as.getConfigValue(ctx, action.Config, constants.ConfigPayload, actionCtx, action.ObjectAPIName); err == nil && payload != nil {
		payloadBytes, err = json.Marshal(payload)
		if err != nil {
			return apperrors.NewInternalError("failed to serialize webhook payload", err)
		}
	}

	// Collect custom headers before building the request so the live send uses
	// real values. Headers are sent with real values but STORED scrubbed
	// (allowlist Content-Type only) — credentials never persist in attempt rows;
	// resend re-resolves headers from the action's current config.
	frozenHeaders := buildWebhookHeaders(action.Config)

	// Validate URL policy explicitly so admin-configured URL mistakes surface
	// as user-facing ValidationError("url", ...) instead of an opaque HTTP 500.
	// This preserves the pre-migration error semantics on the admin self-service path.
	// WebhookClient.Do also calls EnsureAllowed internally, but the explicit preflight
	// here converts the error class before the request is constructed.
	if err := as.allowlist.EnsureAllowed(webhookURL); err != nil {
		return apperrors.NewValidationError("url", as.allowlist.RedactError(err).Error())
	}

	// Create HTTP request. SSRF prevention, cred-aware redirect checks, and the
	// 30s client-level Timeout are handled by as.webhookClient (WebhookClient).
	var bodyReader io.Reader
	if len(payloadBytes) > 0 {
		bodyReader = bytes.NewReader(payloadBytes)
	}
	req, err := http.NewRequestWithContext(ctx, method, webhookURL, bodyReader)
	if err != nil {
		return apperrors.NewInternalError("failed to create webhook request", err)
	}

	for k, v := range frozenHeaders {
		req.Header.Set(k, v)
	}

	// Evidence ActionID rule: a persisted _System_Action row id whenever an
	// automation action authored the send (composite steps record the ROOT id,
	// never a synthesized local id); EMPTY only for Flow-emitted email, which has
	// no authoring persisted action (FlowActionCall has no stable action id —
	// documented limitation). Flow-invoked actions (incl. webhook) resolve
	// persisted metadata via GetLiveActionByID, so webhook rows always carry a real id.
	// Composite steps substitute the outermost composite's persisted id and freeze
	// the local step id for payload addressing so operator resend can locate the
	// step's current config.
	attemptActionID := action.ID
	attemptStepID := ""
	// Freeze the authoring-config fingerprint at EVERY addressing level:
	// resend replays only against a byte-identical config (any edit refuses),
	// so the original write must pin the identity for top-level rows too —
	// _System_Action rows are mutable and an admin retarget must never route
	// a frozen historical payload to the new endpoint. Inside a composite the
	// executor materializes each step as a pseudo-action whose Config IS the
	// step config, so this same expression freezes the right preimage there.
	attemptStepFingerprint := as.stepConfigFingerprint(action.Config)
	if actionCtx != nil && actionCtx.CompositeRootActionID != "" && actionCtx.CompositeRootActionID != action.ID {
		attemptActionID = actionCtx.CompositeRootActionID
		attemptStepID = action.ID
	}

	// recordWebhookAttempt is a local helper that centralises recording across
	// all three return paths (C1). It is called after every webhookClient.Do
	// exit — transport error, status>=400, and success.
	// C6: if the attempt write itself fails after a successful send, swallow.
	recordWebhookAttempt := func(status *int, sendErr error) {
		if as.attemptRecorder == nil {
			return
		}
		sourceRecordID := ""
		if actionCtx != nil && actionCtx.Record != nil {
			if v, ok := actionCtx.Record[constants.FieldID]; ok {
				sourceRecordID = fmt.Sprintf("%v", v)
			}
		}
		createdByID := ""
		if actionCtx != nil && actionCtx.User != nil {
			createdByID = actionCtx.User.ID
		}
		attemptStatus := externaldelivery.StatusSuccess
		errMsg := ""
		if sendErr != nil {
			attemptStatus = externaldelivery.StatusFailed
			errMsg = externaldelivery.TruncateErrorMessage(sendErr)
		}
		_, recordErr := as.attemptRecorder.RecordAttempt(ctx, externaldelivery.AttemptWrite{
			Channel:         externaldelivery.ChannelWebhook,
			Status:          attemptStatus,
			SourceObjectAPI: action.ObjectAPIName,
			SourceRecordID:  sourceRecordID,
			// ActionID: persisted root id (see Evidence ActionID rule above).
			// StepID addresses the webhook step inside a composite config for resend.
			ActionID:      attemptActionID,
			TargetSummary: as.allowlist.Redact(webhookURL),
			WebhookPayload: &externaldelivery.WebhookPayload{
				// Store host-only url: userinfo/path/query are credential positions
				// (e.g. user:pass@host, /services/T/B/SECRET, ?api_key=K); the live
				// request still uses the full webhookURL resolved above.
				URL:             as.allowlist.Redact(webhookURL),
				Method:          method,
				Headers:         externaldelivery.ScrubHeadersForStorage(frozenHeaders),
				Body:            string(payloadBytes),
				StepID:          attemptStepID,
				StepFingerprint: attemptStepFingerprint,
			},
			ResponseStatus: status,
			ErrorMessage:   errMsg,
			CreatedByID:    createdByID,
		})
		if recordErr != nil {
			// Always log a record-write failure — even when the send also failed —
			// so a double-failure (delivery down + DB down) leaves an operator trace
			// instead of silently dropping the attempt row. C6: never fail the send.
			// One log contract with the email path: print the sanitized errMsg
			// (here already redacted/self-constructed), never the raw variable.
			if as.logger != nil {
				as.logger.Info(fmt.Sprintf("WEBHOOK: attempt record write failed (sendErrSanitized=%q): %v", errMsg, recordErr))
			}
		}
	}

	// Execute via the shared outbound adapter (SSRF-safe).
	resp, err := as.webhookClient.Do(req)
	if err != nil {
		// Redact any *url.Error so a secret in the webhook URL (e.g.
		// ?api_key=...) does not surface via Go's stdlib *url.Error.Error()
		// which embeds the raw URL string verbatim. Matches connector_callout
		// engine.go:287 caller-side redact pattern.
		err = as.allowlist.RedactError(err)
		as.logger.Error(fmt.Sprintf("⚠️ WEBHOOK FAILED: URL=%s Method=%s", as.allowlist.Redact(webhookURL), method), err)
		recordWebhookAttempt(nil, err) // C1: record on transport failure
		return apperrors.NewInternalError("webhook request failed", err)
	}
	defer func() { _ = resp.Body.Close() }()
	// Drain body to allow connection reuse.
	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))

	// Check response status
	if resp.StatusCode >= 400 {
		as.logger.Error(fmt.Sprintf("WEBHOOK ERROR RESPONSE: URL=%s Status=%d", as.allowlist.Redact(webhookURL), resp.StatusCode), nil)
		statusCode := resp.StatusCode
		recordWebhookAttempt(&statusCode, fmt.Errorf("webhook returned error status: %d", resp.StatusCode)) // C1: record on 4xx/5xx
		return apperrors.NewInternalError(fmt.Sprintf("webhook returned error status: %d", resp.StatusCode), nil)
	}

	as.logger.Info(fmt.Sprintf("WEBHOOK SUCCESS: URL=%s Method=%s Status=%d", as.allowlist.Redact(webhookURL), method, resp.StatusCode))
	statusCode := resp.StatusCode
	recordWebhookAttempt(&statusCode, nil) // C1: record on success
	return nil
}

// getConfigValue extracts a value from action config and evaluates it if it's a formula
func (as *ActionService) getConfigValue(ctx context.Context, config map[string]interface{}, key string, actionCtx *ActionContext, sourceObjectName string) (interface{}, error) {
	value, exists := config[key]
	if !exists {
		return nil, apperrors.NewValidationError(key, fmt.Sprintf("%s not specified in action config", key))
	}

	// Evaluate if it's a formula
	return as.evaluateRef(ctx, value, actionCtx, sourceObjectName)
}

// isActionFormulaRef reports whether an action-config string value is a
// formula reference ({!...}) that requires the record/flow context to
// evaluate. Single predicate shared by evaluateRef and ResolveWebhookTarget.
func isActionFormulaRef(s string) bool {
	return strings.HasPrefix(s, "{!") && strings.HasSuffix(s, "}")
}

// evaluateRef evaluates a value using the metadata-driven Formula Engine
func (as *ActionService) evaluateRef(ctx context.Context, value interface{}, actionCtx *ActionContext, sourceObjectName string) (interface{}, error) {
	// If it's a string, check if it's a formula
	strValue, ok := value.(string)
	if !ok {
		// Return literal value as-is
		return value, nil
	}

	// Check if it's a formula (starts with {! and ends with })
	if !isActionFormulaRef(strValue) {
		// Return literal string
		return strValue, nil
	}

	// Extract formula content
	expression := strings.TrimSpace(strValue[2 : len(strValue)-1])
	expression = normalizeActionFormulaExpression(expression)

	// Prepare Formula Engine Context from ActionContext
	formulaCtx := &formula.Context{
		Record: actionCtx.Record,
		Env:    make(map[string]interface{}), // Add Env if needed
	}
	if as.metadata != nil && sourceObjectName != "" {
		formulaCtx.Schema = as.metadata.GetSchema(ctx, sourceObjectName)
	}

	// Add FLS check if user is present
	if actionCtx.User != nil && as.permissions != nil {
		formulaCtx.IsVisible = func(fieldName string) (bool, error) {
			// Use provided source object name
			if sourceObjectName != "" {
				return as.permissions.CheckFieldVisibilityWithUser(ctx, sourceObjectName, fieldName, actionCtx.User)
			}
			// Fallback: If we can't determine object, we default to hidden for safety
			return false, nil
		}
	}

	// Source $User / $Profile / $UserRole formula globals via the shared
	// formula.BuildFormulaUserContext — the SINGLE read path also used by
	// records/persistence_service.go:buildUserContext,
	// automation/executor_flow_formula_helpers.go:buildFlowFormulaContext, and
	// approval_service.go.
	//
	// IMPORTANT: do NOT set formulaCtx.User here. The formula engine flattens
	// ctx.User into the top-level expression environment AFTER record fields,
	// so a `User.Id` from the helper would clobber `Record.Id` at the top
	// level — breaking action/email-template expressions like `{!Id}` /
	// `{!Name}` that resolve top-level Id against the running record. Instead,
	// expose the helper's __User projection as a Variables entry under the
	// "user" key. Engine.go's Variables flatten writes Variables top-level
	// keys directly into env (one map per key, e.g. env["user"], env["__User"]
	// — without further flattening their inner sub-map keys), so:
	//   - normalizeActionFormulaExpression rewrites `User.X` → `user.X`, which
	//     resolves via env["user"]["X"] (the legacy action namespace, the
	//     actual resolution path on the action side).
	//   - bare `{!Id}` resolves via env["Id"] from RECORD flatten only —
	//     no user clobber.
	//   - The __User / __Profile / __UserRole keys in Variables are also
	//     written into env (env["__User"], env["__Profile"], env["__UserRole"]).
	//     They are unused on the action path (action expressions don't take
	//     the dollar-rewrite path) but kept for cross-consumer SSoT symmetry
	//     with validation/flow/approval which DO resolve through __User et al
	//     via the formula engine's dollarVarPattern rewrite.
	if actionCtx.User != nil {
		userCtx := formula.BuildFormulaUserContext(actionCtx.User)
		if userMap, ok := userCtx["__User"].(map[string]interface{}); ok {
			userCtx["user"] = userMap
		}
		formulaCtx.Variables = userCtx
	} else {
		// Preserve prior behavior for nil-user paths (system/background
		// actions): empty `user` namespace exists so user.X references
		// don't error.
		formulaCtx.Variables = map[string]interface{}{
			"user": map[string]interface{}{},
		}
	}

	// Add Results from previous steps as custom fields.
	// Flatten Results: results.stepId.field -> accessing via formula context.
	// We convert map[string]map[string]interface{} to map[string]interface{} for the "results" key.
	resultsMap := make(map[string]interface{})
	for k, v := range actionCtx.Results {
		resultsMap[k] = v
	}

	if formulaCtx.Fields == nil {
		formulaCtx.Fields = make(map[string]interface{})
	}
	formulaCtx.Fields[constants.ConfigKeyResults] = resultsMap

	// Inject $Permission.X keys as true for super-user capability holders so
	// action expressions like $Permission.SomeCustomPerm resolve correctly
	// without hardcoding L1 permission names in platform code. Issue #609.
	if actionCtx.User != nil {
		formula.InjectSuperUserPermissions(formulaCtx.Variables, expression,
			userHasSuperUserCapability(ctx, actionCtx.User, as.permissions))
	}

	// Evaluate using shared engine
	return as.formula.Evaluate(expression, formulaCtx)
}

func normalizeActionFormulaExpression(expression string) string {
	switch {
	case strings.HasPrefix(expression, "Record."):
		return "record." + strings.TrimPrefix(expression, "Record.")
	case strings.HasPrefix(expression, "User."):
		return "user." + strings.TrimPrefix(expression, "User.")
	default:
		return expression
	}
}
