package app

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"strings"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/core/identifiers"
	"github.com/tidbcloud/AgentTeam/internal/core/runeffect"
	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/model"
	"github.com/tidbcloud/AgentTeam/internal/store/serviceactor"
)

// GetAppNotificationForRuntime reads a App-owned notification through its
// transport-independent runtime principal.
func (store *Store) GetAppNotificationForRuntime(ctx context.Context, authenticated AppNotificationRuntimePrincipal, notificationID string) (AppNotificationResult, error) {
	if err := store.authorizeAppNotificationRuntimeCapability(ctx, authenticated.ServiceInstallationID); err != nil {
		return AppNotificationResult{}, err
	}
	if err := requireAppInstallationScopes(ctx, store.db, authenticated.ServiceInstallationID, []string{AppNotificationScopeWrite}); err != nil {
		return AppNotificationResult{}, err
	}
	return store.getAppNotificationForInstallation(ctx, authenticated.WorkspaceID, authenticated.ServiceInstallationID, notificationID)
}

// GetAppNotificationForInternal reads a notification through the trusted
// service's registered installation.
func (store *Store) GetAppNotificationForInternal(ctx context.Context, principal AppNotificationInternalPrincipal, notificationID string) (AppNotificationResult, error) {
	notification, err := store.LoadAppNotification(ctx, principal.WorkspaceID, notificationID)
	if err != nil {
		return AppNotificationResult{}, err
	}
	if err := principal.authorizeNotificationRecord(notification); err != nil {
		return AppNotificationResult{}, err
	}
	return store.appNotificationResultInTx(ctx, store.db, notification)
}

func (store *Store) GetAppNotificationForBuiltin(ctx context.Context, identityID, workspaceID, notificationID string) (AppNotificationResult, error) {
	principal, err := store.AppNotificationBuiltinPrincipal(ctx, workspaceID, identityID)
	if err != nil {
		return AppNotificationResult{}, err
	}
	return store.GetAppNotificationForInternal(ctx, principal, notificationID)
}

func (store *Store) getAppNotificationForInstallation(ctx context.Context, workspaceID, installationID, notificationID string) (AppNotificationResult, error) {
	notification, err := store.LoadAppNotification(ctx, workspaceID, notificationID)
	if err != nil {
		return AppNotificationResult{}, err
	}
	if strings.TrimSpace(notification.ServiceInstallationID) != strings.TrimSpace(installationID) {
		return AppNotificationResult{}, ErrNotFound
	}
	return store.appNotificationResultInTx(ctx, store.db, notification)
}

// MutateAppNotificationForRuntime applies a runtime-principal mutation.
func (store *Store) MutateAppNotificationForRuntime(ctx context.Context, authenticated AppNotificationRuntimePrincipal, command AppNotificationMutationCommand) (AppNotificationResult, error) {
	command.WorkspaceID = authenticated.WorkspaceID
	command.ServiceInstallationID = authenticated.ServiceInstallationID
	command.Ingress = AppNotificationIngressRuntime
	command.ExecutionActor = authenticated.executionActor()
	return store.MutateAppNotification(ctx, command)
}

// MutateAppNotificationForDelivery applies a mutation as the currently leased
// App action delivery and records the resulting notification effect in the
// same transaction. Ack can then consume that effect.
func (store *Store) MutateAppNotificationForDelivery(ctx context.Context, authenticated AppRuntimeDeliveryAuth, command AppNotificationMutationCommand) (AppNotificationResult, error) {
	command.WorkspaceID = authenticated.WorkspaceID
	command.ServiceInstallationID = authenticated.ServiceInstallationID
	command.Ingress = AppNotificationIngressRuntime
	command.ExecutionActor = AppNotificationExecutionActor{Type: appNotificationExecutionActorAppRuntime, ID: authenticated.DeliveryID}
	command.ServiceEventDeliveryID = authenticated.DeliveryID
	command.DeliveryAttemptID = authenticated.AttemptID
	command.DeliveryBearerToken = authenticated.BearerToken
	return store.MutateAppNotification(ctx, command)
}

// MutateAppNotificationForInternal applies a registered first-party mutation.
func (store *Store) MutateAppNotificationForInternal(ctx context.Context, principal AppNotificationInternalPrincipal, command AppNotificationMutationCommand) (AppNotificationResult, error) {
	command.WorkspaceID = principal.WorkspaceID
	command.ServiceInstallationID = principal.ServiceInstallationID
	command.Ingress = AppNotificationIngressInternal
	command.ExecutionActor = AppNotificationExecutionActor{Type: appNotificationExecutionActorInternalService, ID: principal.IdentityID}
	notification, err := store.LoadAppNotification(ctx, principal.WorkspaceID, command.NotificationID)
	if err != nil {
		return AppNotificationResult{}, err
	}
	if err := principal.authorizeNotificationRecord(notification); err != nil {
		return AppNotificationResult{}, err
	}
	if command.Content != nil {
		if _, ok := principal.service.AllowedContentKinds[strings.TrimSpace(command.Content.Kind)]; !ok {
			return AppNotificationResult{}, forbidden("trusted internal service is not allowed to mutate this App notification content")
		}
	}
	return store.MutateAppNotification(ctx, command)
}

func (store *Store) MutateAppNotificationForBuiltin(ctx context.Context, identityID, workspaceID string, command AppNotificationMutationCommand) (AppNotificationResult, error) {
	principal, err := store.AppNotificationBuiltinPrincipal(ctx, workspaceID, identityID)
	if err != nil {
		return AppNotificationResult{}, err
	}
	return store.MutateAppNotificationForInternal(ctx, principal, command)
}

// MutateAppNotificationForBuiltinDelivery applies a mutation from the current
// durable in-process App action lease. The delivery and attempt ids are
// revalidated inside the mutation transaction; unlike the external runtime
// ingress, this path never accepts or creates a bearer token.
func (store *Store) MutateAppNotificationForBuiltinDelivery(ctx context.Context, identityID, workspaceID, deliveryID, attemptID string, command AppNotificationMutationCommand) (AppNotificationResult, error) {
	principal, err := store.AppNotificationBuiltinPrincipal(ctx, workspaceID, identityID)
	if err != nil {
		return AppNotificationResult{}, err
	}
	command.WorkspaceID = principal.WorkspaceID
	command.ServiceInstallationID = principal.ServiceInstallationID
	command.Ingress = AppNotificationIngressInternal
	command.ExecutionActor = AppNotificationExecutionActor{Type: appNotificationExecutionActorInternalService, ID: principal.IdentityID}
	command.CurrentInternalDeliveryID = strings.TrimSpace(deliveryID)
	command.CurrentInternalAttemptID = strings.TrimSpace(attemptID)
	if command.CurrentInternalDeliveryID == "" || command.CurrentInternalAttemptID == "" {
		return AppNotificationResult{}, badRequest("builtin App notification mutation requires the current delivery and attempt")
	}
	notification, err := store.LoadAppNotification(ctx, principal.WorkspaceID, command.NotificationID)
	if err != nil {
		return AppNotificationResult{}, err
	}
	if err := principal.authorizeNotificationRecord(notification); err != nil {
		return AppNotificationResult{}, err
	}
	if command.Content != nil {
		if _, ok := principal.service.AllowedContentKinds[strings.TrimSpace(command.Content.Kind)]; !ok {
			return AppNotificationResult{}, forbidden("trusted internal service is not allowed to mutate this App notification content")
		}
	}
	if command.Kind == AppNotificationMutationResolve {
		if err := store.runBuiltinDeliveryMutationFaultHook(ctx, command); err != nil {
			return AppNotificationResult{}, err
		}
	}
	return store.MutateAppNotification(ctx, command)
}

// MutateAppNotification is the shared optimistic, idempotent mutation
// transaction for update/resolve/withdraw/expiry.
func (store *Store) MutateAppNotification(ctx context.Context, command AppNotificationMutationCommand) (AppNotificationResult, error) {
	now := time.Now().UTC()
	normalized, digest, card, err := validateAndNormalizeAppNotificationMutationAt(command, now)
	if err != nil {
		return AppNotificationResult{}, err
	}
	command = normalized
	if command.CorrelationID == "" {
		command.CorrelationID, err = identifiers.NewEntityID("cor")
		if err != nil {
			return AppNotificationResult{}, fmt.Errorf("generate App notification mutation correlation id: %w", err)
		}
	}

	var result AppNotificationResult
	var messageMutation message.ServiceNotificationMessageMutationResult
	err = coredb.WithTx(ctx, store.db, func(tx coredb.Tx) error {
		// Notification creation serializes on the installation before touching
		// the notification idempotency row. Mutations use the same order so an
		// idempotent create retry cannot deadlock a concurrent update.
		lockedInstallation, err := store.serviceActorStore().InTx(tx).LoadInstallationForUpdate(ctx, command.ServiceInstallationID)
		if err != nil {
			return err
		}
		if lockedInstallation.WorkspaceID != command.WorkspaceID || lockedInstallation.ServiceKind != serviceactor.KindApp {
			return ErrNotFound
		}
		runtimeCapability, err := requirePinnedRuntimeCapability(lockedInstallation)
		if err != nil {
			return err
		}
		notification, err := store.LoadAppNotificationInTx(ctx, tx, command.WorkspaceID, command.NotificationID, true)
		if err != nil {
			return err
		}
		if notification.ServiceInstallationID != command.ServiceInstallationID {
			return ErrNotFound
		}
		preserveActionDeliveryID := ""
		if command.CurrentInternalDeliveryID != "" || command.CurrentInternalAttemptID != "" {
			if err := store.authorizeBuiltinAppNotificationMutationDeliveryInTx(ctx, tx, notification, command, now); err != nil {
				return err
			}
			preserveActionDeliveryID = command.CurrentInternalDeliveryID
		}
		if receipt, found, err := store.FindAppNotificationMutationInTx(ctx, tx, command.WorkspaceID, notification.ID, command.ClientMutationID, true); err != nil {
			return err
		} else if found {
			if receipt.PayloadDigest != digest || receipt.MutationKind != command.Kind {
				return conflict("App notification mutation id was already used with another payload")
			}
			if len(receipt.ResultState) == 0 || receipt.CompletedAt == nil {
				return conflict("App notification mutation is still being applied")
			}
			if command.ServiceEventDeliveryID != "" {
				if receipt.ServiceEventDeliveryID == nil || strings.TrimSpace(*receipt.ServiceEventDeliveryID) != command.ServiceEventDeliveryID {
					return conflict("App notification mutation receipt belongs to another execution")
				}
				current, err := store.authorizeAppNotificationMutationDeliveryInTx(ctx, tx, notification, command, now)
				if err != nil {
					return err
				}
				if _, err := store.appendAppNotificationMutationEffectInTx(ctx, tx, current, notification, receipt.ID, receipt.MutationKind, now); err != nil {
					return err
				}
			}
			if err := json.Unmarshal(receipt.ResultState, &result); err != nil {
				return fmt.Errorf("decode App notification mutation replay: %w", err)
			}
			if err := store.enqueueAppNotificationMutationFanoutInTx(ctx, tx, receipt.ID, result, now); err != nil {
				return err
			}
			result.AlreadyApplied = true
			return nil
		}
		if command.Kind == AppNotificationMutationExpire {
			if notification.ExpiresAt == nil || notification.ExpiresAt.After(now) {
				return conflict("App notification has not reached its expiry")
			}
		} else if notification.ExpiresAt != nil && !notification.ExpiresAt.After(now) {
			return conflict("App notification is expired")
		}
		if notification.Revision != command.ExpectedRevision {
			return conflict("App notification revision changed")
		}
		if err := authorizeAppNotificationMutationState(notification.Status, command.Kind); err != nil {
			return err
		}
		installation, serviceMember, binding, err := store.authorizeExistingAppNotificationInTx(ctx, tx, notification, command.Kind)
		if err != nil {
			return err
		}
		if command.BusinessObjectVersion != nil {
			if notification.BusinessObjectVersion == nil || *command.BusinessObjectVersion < *notification.BusinessObjectVersion {
				return conflict("App notification business object version cannot move backwards")
			}
		}
		if command.Kind != AppNotificationMutationExpire {
			if err := store.authorizeCurrentAppNotificationRecipientMutationInTx(ctx, tx, notification, command.BusinessObjectVersion, now); err != nil {
				return err
			}
		}
		if command.Content != nil {
			if err := authorizeAppNotificationManifest(runtimeCapability, AppNotificationCommand{
				Target:  AppNotificationTarget{Kind: notification.TargetKind},
				Content: *command.Content,
			}, card); err != nil {
				return err
			}
		}
		updateHasCallbackAction := command.Kind == AppNotificationMutationUpdate && card != nil && hasAppNotificationCallbackAction(*card)
		if updateHasCallbackAction {
			if err := requireAppInstallationScopes(ctx, tx, notification.ServiceInstallationID, []string{AppNotificationActionScopeRead}); err != nil {
				return err
			}
		}
		var deliveryAuth *AppRuntimeDeliveryAuth
		if command.ServiceEventDeliveryID != "" {
			current, err := store.authorizeAppNotificationMutationDeliveryInTx(ctx, tx, notification, command, now)
			if err != nil {
				return err
			}
			deliveryAuth = &current
			preserveActionDeliveryID = current.Delivery.ID
		}

		next := notification
		next.Revision = notification.Revision + 1
		next.ExecutionActorType = command.ExecutionActor.Type
		next.ExecutionActorID = command.ExecutionActor.ID
		next.CorrelationID = command.CorrelationID
		next.UpdatedAt = now
		if command.CausedByActor.Type != "" {
			next.CausedByActorType = command.CausedByActor.Type
			next.CausedByActorID = command.CausedByActor.ID
		}
		if command.BusinessObjectVersion != nil {
			version := *command.BusinessObjectVersion
			next.BusinessObjectVersion = &version
		}

		// messageMutation is declared at function scope (above WithTx) so its
		// DeletedAttachmentDerivatives is available for post-commit cleanup.
		expectedEditRevision, err := loadAppNotificationMessageEditRevision(ctx, tx, notification)
		if err != nil {
			return err
		}
		switch command.Kind {
		case AppNotificationMutationUpdate, AppNotificationMutationResolve:
			if command.Content == nil {
				return badRequest("App notification update content is required")
			}
			if command.Content.Kind != notification.ContentKind {
				return badRequest("App notification content kind cannot change")
			}
			visibleContent := command.Content.Text
			if command.Content.Kind == AppNotificationContentInteractiveCard {
				visibleContent = command.Content.FallbackText
			}
			encodedMetadata, _, err := message.EncodeMessageMetadata(message.CopyMessageMetadata(command.Content.Metadata))
			if err != nil {
				return err
			}
			messageMutation, err = message.UpdateServiceNotificationMessageTx(ctx, tx, message.ServiceNotificationMessageUpdate{
				WorkspaceID: notification.WorkspaceID, ScopeID: notification.ScopeID, MessageID: notification.MessageID,
				AuthorMemberID: serviceMember.MemberID, ExpectedEditRevision: &expectedEditRevision,
				Content: visibleContent, Metadata: encodedMetadata, UpdatedAt: now,
			})
			if err != nil {
				return err
			}
			if command.Kind == AppNotificationMutationResolve {
				next.Status = AppNotificationStatusResolved
				next.ResolvedAt = &now
			}
		case AppNotificationMutationWithdraw:
			placeholder := "通知已撤回"
			if command.Reason != "" {
				placeholder = "通知已撤回：" + command.Reason
			}
			messageMutation, err = message.WithdrawServiceNotificationMessageTx(ctx, tx, message.ServiceNotificationMessageWithdrawal{
				WorkspaceID: notification.WorkspaceID, ScopeID: notification.ScopeID, MessageID: notification.MessageID,
				AuthorMemberID: serviceMember.MemberID, ExpectedEditRevision: &expectedEditRevision,
				PlaceholderContent: placeholder, Reason: command.Reason, WithdrawnAt: now,
			})
			if err != nil {
				return err
			}
			next.Status = AppNotificationStatusWithdrawn
			next.WithdrawnAt = &now
		case AppNotificationMutationExpire:
			existingMessage, err := message.NewScopeRepositories(tx).Messages.LoadByID(ctx, notification.WorkspaceID, notification.ScopeID, notification.MessageID)
			if err != nil {
				return err
			}
			messageMutation, err = message.UpdateServiceNotificationMessageTx(ctx, tx, message.ServiceNotificationMessageUpdate{
				WorkspaceID: notification.WorkspaceID, ScopeID: notification.ScopeID, MessageID: notification.MessageID,
				AuthorMemberID: serviceMember.MemberID, ExpectedEditRevision: &expectedEditRevision,
				Content: existingMessage.Content, Metadata: existingMessage.Metadata, UpdatedAt: now,
			})
			if err != nil {
				return err
			}
			next.Status = AppNotificationStatusExpired
		default:
			return badRequest("App notification mutation kind is not supported")
		}

		if notification.ContentKind == AppNotificationContentInteractiveCard {
			currentCard, err := store.LoadAppNotificationCardByNotificationInTx(ctx, tx, notification.ID, true)
			if err != nil {
				return err
			}
			nextCard := currentCard
			nextCard.CardRevision = currentCard.CardRevision + 1
			nextCard.UpdatedAt = now
			switch command.Kind {
			case AppNotificationMutationUpdate:
				nextCard.State = AppNotificationCardStateActive
			case AppNotificationMutationResolve:
				nextCard.State = AppNotificationCardStateResolved
			case AppNotificationMutationWithdraw:
				nextCard.State = AppNotificationCardStateWithdrawn
			case AppNotificationMutationExpire:
				nextCard.State = AppNotificationCardStateExpired
			}
			if card != nil {
				nextCard.SchemaVersion = card.SchemaVersion
				nextCard.RenderPayloadJSON = command.Content.CardJSON
				actionSchema, err := appNotificationDeterministicJSON(map[string]any{"actions": card.Actions})
				if err != nil {
					return err
				}
				nextCard.ActionSchemaJSON = actionSchema
			}
			if changed, err := store.UpdateAppNotificationCardInTx(ctx, tx, nextCard, currentCard.CardRevision, currentCard.State); err != nil {
				return err
			} else if !changed {
				return conflict("App notification card changed concurrently")
			}
		}
		if changed, err := store.UpdateAppNotificationInTx(ctx, tx, next, notification.Revision, notification.Status); err != nil {
			return err
		} else if !changed {
			return conflict("App notification changed concurrently")
		}
		if updateHasCallbackAction {
			resolved := resolvedAppNotificationTarget{
				Target: ServiceEventTarget{
					TargetKind: binding.TargetKind, TargetRef: binding.TargetRef,
					DeliveryScopeID: binding.DeliveryScopeID, PermissionCheckScopeID: binding.PermissionCheckScopeID,
				},
				Binding: binding, RecipientMemberID: notification.RecipientMemberID,
			}
			if _, err := store.ensureAppNotificationActionSubscriptionInTx(ctx, tx, installation, serviceMember, resolved, next, AppNotificationCommand{
				Ingress: command.Ingress, ExecutionActor: command.ExecutionActor,
			}, now); err != nil {
				return err
			}
		} else if command.Kind != AppNotificationMutationUpdate || notification.ContentKind == AppNotificationContentInteractiveCard {
			if err := store.closeAppNotificationActionSubscriptionsInTx(ctx, tx, notification, preserveActionDeliveryID, now); err != nil {
				return err
			}
		}

		result, err = store.appNotificationResultInTx(ctx, tx, next)
		if err != nil {
			return err
		}
		if messageMutation.Message.ID != "" {
			appMessage, err := store.appNotificationMutationAppMessageInTx(ctx, tx, next, binding, installation, serviceMember, messageMutation.Message)
			if err != nil {
				return err
			}
			result.MessageMutation = &appMessage
			result.MessageMutationIsLatestVisible = messageMutation.IsLatestVisibleMessage
			if command.Kind == AppNotificationMutationWithdraw {
				result.MessageMutationKind = "delete"
			} else {
				result.MessageMutationKind = "update"
			}
		}
		resultState, err := appNotificationDeterministicJSON(result)
		if err != nil {
			return err
		}
		mutationID, err := identifiers.NewEntityID("btnm")
		if err != nil {
			return fmt.Errorf("generate App notification mutation id: %w", err)
		}
		completedAt := now
		messageEditRevision := expectedEditRevision
		if messageMutation.Message.ID != "" {
			messageEditRevision = messageMutation.Message.EditRevision
		}
		var deliveryID *string
		if deliveryAuth != nil {
			deliveryID = stringPointer(deliveryAuth.Delivery.ID)
		}
		if err := store.InsertAppNotificationMutationInTx(ctx, tx, AppNotificationMutationRecord{
			ID: mutationID, WorkspaceID: notification.WorkspaceID, AppNotificationID: notification.ID,
			MutationKind: command.Kind, ClientMutationID: command.ClientMutationID,
			ExpectedRevision: command.ExpectedRevision, ResultRevision: next.Revision, PayloadDigest: digest,
			ResultState: resultState, MessageEditRevision: messageEditRevision, ServiceEventDeliveryID: deliveryID,
			CausedByActorType: next.CausedByActorType, CausedByActorID: next.CausedByActorID,
			ExecutionActorType: command.ExecutionActor.Type, ExecutionActorID: command.ExecutionActor.ID,
			CorrelationID: command.CorrelationID, CreatedAt: now, CompletedAt: &completedAt,
		}); err != nil {
			return err
		}
		if deliveryAuth != nil {
			if _, err := store.appendAppNotificationMutationEffectInTx(ctx, tx, *deliveryAuth, next, mutationID, command.Kind, now); err != nil {
				return err
			}
		}
		if err := store.enqueueAppNotificationMutationFanoutInTx(ctx, tx, mutationID, result, now); err != nil {
			return err
		}
		return nil
	})
	if err != nil {
		return AppNotificationResult{}, err
	}
	// Post-commit best-effort derivative cleanup for withdrawn messages.
	store.messageStore().CleanupAttachmentDerivatives(ctx, messageMutation.DeletedAttachmentDerivatives)
	return result, nil
}

func validateAndNormalizeAppNotificationMutation(command AppNotificationMutationCommand) (AppNotificationMutationCommand, string, *appNotificationCardDocument, error) {
	return validateAndNormalizeAppNotificationMutationAt(command, time.Now().UTC())
}

func validateAndNormalizeAppNotificationMutationAt(command AppNotificationMutationCommand, now time.Time) (AppNotificationMutationCommand, string, *appNotificationCardDocument, error) {
	command.WorkspaceID = strings.TrimSpace(command.WorkspaceID)
	command.ServiceInstallationID = strings.TrimSpace(command.ServiceInstallationID)
	command.Ingress = strings.TrimSpace(command.Ingress)
	command.ExecutionActor.Type = strings.TrimSpace(command.ExecutionActor.Type)
	command.ExecutionActor.ID = strings.TrimSpace(command.ExecutionActor.ID)
	command.NotificationID = strings.TrimSpace(command.NotificationID)
	command.Kind = strings.TrimSpace(command.Kind)
	command.ClientMutationID = strings.TrimSpace(command.ClientMutationID)
	command.Reason = strings.TrimSpace(command.Reason)
	command.CausedByActor.Type = strings.TrimSpace(command.CausedByActor.Type)
	command.CausedByActor.ID = strings.TrimSpace(command.CausedByActor.ID)
	command.CorrelationID = strings.TrimSpace(command.CorrelationID)
	command.ServiceEventDeliveryID = strings.TrimSpace(command.ServiceEventDeliveryID)
	command.DeliveryAttemptID = strings.TrimSpace(command.DeliveryAttemptID)
	command.CurrentInternalDeliveryID = strings.TrimSpace(command.CurrentInternalDeliveryID)
	command.CurrentInternalAttemptID = strings.TrimSpace(command.CurrentInternalAttemptID)
	if command.WorkspaceID == "" || command.ServiceInstallationID == "" || command.NotificationID == "" || command.ClientMutationID == "" || command.ExpectedRevision <= 0 {
		return AppNotificationMutationCommand{}, "", nil, badRequest("App notification mutation workspace, installation, notification, expected revision, and client mutation id are required")
	}
	if command.ExecutionActor.Type == "" || command.ExecutionActor.ID == "" {
		return AppNotificationMutationCommand{}, "", nil, badRequest("App notification mutation execution actor is required")
	}
	if command.Ingress != AppNotificationIngressRuntime && command.Ingress != AppNotificationIngressInternal {
		return AppNotificationMutationCommand{}, "", nil, badRequest("App notification mutation ingress is not supported")
	}
	if (command.CurrentInternalDeliveryID == "") != (command.CurrentInternalAttemptID == "") {
		return AppNotificationMutationCommand{}, "", nil, badRequest("builtin App notification mutation delivery and attempt are required together")
	}
	if (command.ServiceEventDeliveryID == "") != (command.DeliveryAttemptID == "") {
		return AppNotificationMutationCommand{}, "", nil, badRequest("App notification mutation delivery and attempt are required together")
	}
	if command.ServiceEventDeliveryID == "" && command.DeliveryBearerToken != "" {
		return AppNotificationMutationCommand{}, "", nil, badRequest("App notification mutation delivery bearer requires a delivery and attempt")
	}
	if command.ServiceEventDeliveryID != "" && (command.Ingress != AppNotificationIngressRuntime || command.ExecutionActor.Type != appNotificationExecutionActorAppRuntime || command.DeliveryBearerToken == "") {
		return AppNotificationMutationCommand{}, "", nil, badRequest("App notification mutation delivery context is invalid")
	}
	if command.CurrentInternalDeliveryID != "" && (command.Ingress != AppNotificationIngressInternal || command.ExecutionActor.Type != appNotificationExecutionActorInternalService || command.ServiceEventDeliveryID != "" || command.DeliveryAttemptID != "" || command.DeliveryBearerToken != "") {
		return AppNotificationMutationCommand{}, "", nil, badRequest("builtin App notification mutation delivery context is invalid")
	}
	if len(command.ClientMutationID) > 255 || len(command.CorrelationID) > 255 || len(command.ExecutionActor.Type) > 32 || len(command.ExecutionActor.ID) > 255 {
		return AppNotificationMutationCommand{}, "", nil, badRequest("App notification mutation identifier is too long")
	}
	if (command.CausedByActor.Type == "") != (command.CausedByActor.ID == "") {
		return AppNotificationMutationCommand{}, "", nil, badRequest("App notification mutation caused-by actor type and id are required together")
	}
	if len(command.CausedByActor.Type) > 32 || len(command.CausedByActor.ID) > 255 || len(command.Reason) > 1024 {
		return AppNotificationMutationCommand{}, "", nil, badRequest("App notification mutation actor or reason is too long")
	}
	if command.BusinessObjectVersion != nil && *command.BusinessObjectVersion <= 0 {
		return AppNotificationMutationCommand{}, "", nil, badRequest("App notification business object version must be positive")
	}
	var card *appNotificationCardDocument
	if command.Content != nil {
		content, parsedCard, err := validateAndNormalizeAppNotificationContentAt(*command.Content, now)
		if err != nil {
			return AppNotificationMutationCommand{}, "", nil, err
		}
		if len(content.AttachmentIDs) > 0 {
			return AppNotificationMutationCommand{}, "", nil, badRequest("App notification attachments are immutable after creation")
		}
		if content.Kind == AppNotificationContentMessage && content.Text == "" {
			return AppNotificationMutationCommand{}, "", nil, badRequest("App notification message update requires text")
		}
		command.Content = &content
		card = parsedCard
	}
	switch command.Kind {
	case AppNotificationMutationUpdate, AppNotificationMutationResolve:
		if command.Content == nil {
			return AppNotificationMutationCommand{}, "", nil, badRequest("App notification update and resolve require content")
		}
	case AppNotificationMutationWithdraw:
		if command.Content != nil {
			return AppNotificationMutationCommand{}, "", nil, badRequest("App notification withdraw does not accept content")
		}
	case AppNotificationMutationExpire:
		if command.Content != nil || command.Ingress != AppNotificationIngressInternal {
			return AppNotificationMutationCommand{}, "", nil, badRequest("App notification expiry is an internal lifecycle mutation")
		}
	default:
		return AppNotificationMutationCommand{}, "", nil, badRequest("App notification mutation kind is not supported")
	}
	digest, err := appNotificationDigest(struct {
		Kind                  string                  `json:"kind"`
		ExpectedRevision      int64                   `json:"expectedRevision"`
		Content               *AppNotificationContent `json:"content,omitempty"`
		BusinessObjectVersion *int64                  `json:"businessObjectVersion,omitempty"`
		Reason                string                  `json:"reason,omitempty"`
		CausedByActor         AppNotificationActorRef `json:"causedByActor,omitempty"`
	}{command.Kind, command.ExpectedRevision, command.Content, command.BusinessObjectVersion, command.Reason, command.CausedByActor})
	if err != nil {
		return AppNotificationMutationCommand{}, "", nil, err
	}
	return command, digest, card, nil
}

func authorizeAppNotificationMutationState(status, kind string) error {
	switch kind {
	case AppNotificationMutationUpdate, AppNotificationMutationResolve:
		if status != AppNotificationStatusActive {
			return conflict("only active App notifications can be updated or resolved")
		}
	case AppNotificationMutationWithdraw:
		if status != AppNotificationStatusActive && status != AppNotificationStatusResolved {
			return conflict("App notification cannot be withdrawn from its current state")
		}
	case AppNotificationMutationExpire:
		if status != AppNotificationStatusActive {
			return conflict("only active App notifications can expire")
		}
	}
	return nil
}

func (store *Store) authorizeExistingAppNotificationInTx(ctx context.Context, tx coredb.Tx, notification AppNotificationRecord, mutationKind string) (serviceactor.Installation, serviceactor.Member, serviceactor.TargetBinding, error) {
	txServiceActor := store.serviceActorStore().InTx(tx)
	installation, err := txServiceActor.LoadInstallationForUpdate(ctx, notification.ServiceInstallationID)
	if err != nil {
		return serviceactor.Installation{}, serviceactor.Member{}, serviceactor.TargetBinding{}, err
	}
	if installation.WorkspaceID != notification.WorkspaceID || installation.ServiceDefinitionID != notification.ServiceDefinitionID || installation.ServiceKind != serviceactor.KindApp {
		return serviceactor.Installation{}, serviceactor.Member{}, serviceactor.TargetBinding{}, forbidden("App notification installation ownership changed")
	}
	if mutationKind != AppNotificationMutationExpire && installation.Status != serviceactor.StatusActive {
		return serviceactor.Installation{}, serviceactor.Member{}, serviceactor.TargetBinding{}, forbidden("App notification installation is not active")
	}
	member, err := txServiceActor.LoadMemberByInstallation(ctx, installation.ID)
	if err != nil {
		return serviceactor.Installation{}, serviceactor.Member{}, serviceactor.TargetBinding{}, err
	}
	if member.ID != notification.ServiceMemberID || (mutationKind != AppNotificationMutationExpire && member.Status != serviceactor.StatusActive) {
		return serviceactor.Installation{}, serviceactor.Member{}, serviceactor.TargetBinding{}, forbidden("App notification service member ownership changed")
	}
	if mutationKind != AppNotificationMutationExpire {
		if err := requireAppInstallationScopes(ctx, tx, installation.ID, []string{AppRuntimeScopeMessageWrite, AppNotificationScopeWrite}); err != nil {
			return serviceactor.Installation{}, serviceactor.Member{}, serviceactor.TargetBinding{}, err
		}
	}
	var binding serviceactor.TargetBinding
	err = tx.QueryRow(ctx, `
SELECT id, workspace_id, service_definition_id, service_installation_id,
       service_member_id, service_kind, target_kind, target_ref,
       delivery_scope_id, permission_check_scope_id, authority_subject_member_id,
       delivery_contract, status, disabled_reason, created_by_member_id,
       created_by_actor_type, created_by_actor_id, created_via,
       scope_snapshot, metadata, created_at, updated_at, removed_at,
       COALESCE(active_binding_key, '')
FROM service_target_bindings
WHERE id = $1 AND workspace_id = $2
LIMIT 1
FOR UPDATE
`, notification.ServiceTargetBindingID, notification.WorkspaceID).Scan(
		&binding.ID, &binding.WorkspaceID, &binding.ServiceDefinitionID, &binding.ServiceInstallationID,
		&binding.ServiceMemberID, &binding.ServiceKind, &binding.TargetKind, &binding.TargetRef,
		&binding.DeliveryScopeID, &binding.PermissionCheckScopeID, &binding.AuthoritySubjectMemberID,
		&binding.DeliveryContract, &binding.Status, &binding.DisabledReason, &binding.CreatedByMemberID,
		&binding.CreatedByActorType, &binding.CreatedByActorID, &binding.CreatedVia,
		&binding.ScopeSnapshot, &binding.Metadata, &binding.CreatedAt, &binding.UpdatedAt, &binding.RemovedAt,
		&binding.ActiveBindingKey,
	)
	if err != nil {
		return serviceactor.Installation{}, serviceactor.Member{}, serviceactor.TargetBinding{}, fmt.Errorf("load App notification target binding: %w", err)
	}
	if binding.ServiceInstallationID != installation.ID || binding.ServiceMemberID != member.ID || binding.DeliveryScopeID != notification.ScopeID || (mutationKind != AppNotificationMutationExpire && (binding.Status != serviceactor.StatusActive || binding.RemovedAt != nil)) {
		return serviceactor.Installation{}, serviceactor.Member{}, serviceactor.TargetBinding{}, forbidden("App notification target binding ownership changed")
	}
	return installation, member, binding, nil
}

// authorizeCurrentAppNotificationRecipientMutationInTx revalidates the
// owning-domain recipient fact for an existing Human-DM notification. Unlike
// create, mutation convergence intentionally accepts a matching inactive,
// expired, or terminal fact; its identity and version still cannot diverge
// from the durable notification snapshot.
func (store *Store) authorizeCurrentAppNotificationRecipientMutationInTx(ctx context.Context, queryer coredb.QueryRower, notification AppNotificationRecord, businessObjectVersion *int64, now time.Time) error {
	if notification.TargetKind != AppNotificationTargetHumanDM {
		return nil
	}
	recipientMemberID := ""
	if notification.RecipientMemberID != nil {
		recipientMemberID = strings.TrimSpace(*notification.RecipientMemberID)
	}
	basis := appNotificationRecipientBasisFromRecord(notification)
	if recipientMemberID == "" || strings.TrimSpace(notification.TargetRef) != recipientMemberID || basis.Kind == "" || basis.Ref == "" || notification.RecipientFactVersion == nil || *notification.RecipientFactVersion <= 0 {
		return forbidden("App notification recipient snapshot is incomplete or inconsistent")
	}
	resolver, ok := store.appNotificationRecipientResolver(basis.Kind)
	if !ok {
		return forbidden("App notification recipient basis resolver is not registered")
	}
	businessObject := appNotificationBusinessObjectFromRecord(notification)
	if businessObjectVersion != nil {
		if notification.BusinessObjectVersion == nil || *businessObjectVersion < *notification.BusinessObjectVersion {
			return conflict("App notification business object version cannot move backwards")
		}
		businessObject.Version = *businessObjectVersion
	}
	fact, err := resolver.ResolveAppNotificationRecipient(ctx, queryer, AppNotificationRecipientFactRequest{
		WorkspaceID: notification.WorkspaceID, InstallationID: notification.ServiceInstallationID,
		Basis: basis, BusinessObject: businessObject,
		ExpectedMemberID: recipientMemberID, Now: now,
	})
	if err != nil {
		return err
	}
	if strings.TrimSpace(fact.WorkspaceID) != notification.WorkspaceID ||
		strings.TrimSpace(fact.RecipientMemberID) != recipientMemberID ||
		strings.TrimSpace(fact.Kind) != basis.Kind || strings.TrimSpace(fact.Ref) != basis.Ref {
		return forbidden("App notification recipient fact no longer matches the notification")
	}
	if fact.Version <= 0 || fact.Version < *notification.RecipientFactVersion {
		return conflict("App notification recipient fact version cannot move backwards")
	}
	return nil
}

func (store *Store) authorizeAppNotificationMutationDeliveryInTx(ctx context.Context, tx coredb.Tx, notification AppNotificationRecord, command AppNotificationMutationCommand, now time.Time) (AppRuntimeDeliveryAuth, error) {
	current, err := store.authorizeAppRuntimeDeliveryTx(ctx, tx, command.DeliveryBearerToken, command.ServiceEventDeliveryID, command.DeliveryAttemptID, now)
	if err != nil {
		return AppRuntimeDeliveryAuth{}, err
	}
	if err := requireAppRuntimeActiveCurrentAttempt(current, now); err != nil {
		return AppRuntimeDeliveryAuth{}, err
	}
	if current.Delivery.SubscriptionKind != serviceactor.SubscriptionKindAppNotificationAction || current.Delivery.ExternalResourceType != appNotificationExternalResource || current.Delivery.ExternalResourceKey != notification.ID || current.Delivery.ServiceInstallationID != notification.ServiceInstallationID || current.Delivery.ServiceMemberID != notification.ServiceMemberID {
		return AppRuntimeDeliveryAuth{}, forbidden("App runtime delivery cannot mutate this notification")
	}
	attempt, err := store.markAppRuntimeAttemptDelivered(ctx, tx, current.Attempt, now)
	if err != nil {
		return AppRuntimeDeliveryAuth{}, err
	}
	current.Attempt = attempt
	if current.Delivery.SubscriptionKind == serviceactor.SubscriptionKindAppNotificationAction {
		if _, err := store.MarkAppNotificationActionDeliveredInTx(ctx, tx, current.Delivery.WorkspaceID, current.Delivery.EventID, now); err != nil {
			return AppRuntimeDeliveryAuth{}, err
		}
	}
	return current, nil
}

func (store *Store) authorizeBuiltinAppNotificationMutationDeliveryInTx(ctx context.Context, tx coredb.Tx, notification AppNotificationRecord, command AppNotificationMutationCommand, now time.Time) error {
	if command.Ingress != AppNotificationIngressInternal || command.ExecutionActor.Type != appNotificationExecutionActorInternalService {
		return forbidden("App notification mutation is not an internal handler delivery")
	}
	store.appNotificationRegistryMu.RLock()
	registered, ok := store.appNotificationInternalServices[strings.TrimSpace(command.ExecutionActor.ID)]
	store.appNotificationRegistryMu.RUnlock()
	if !ok || strings.TrimSpace(registered.ActionHandlerKey) == "" || registered.HasSecret {
		return ErrUnauthorized
	}
	handlerRef, err := serviceactor.InternalHandlerConnectionRef(registered.ActionHandlerKey)
	if err != nil {
		return ErrUnauthorized
	}
	txServiceActor := store.serviceActorStore().InTx(tx)
	attempt, err := txServiceActor.LoadDeliveryAttemptForUpdate(ctx, command.WorkspaceID, command.CurrentInternalAttemptID)
	if err != nil {
		return err
	}
	if attempt.ServiceEventDeliveryID != command.CurrentInternalDeliveryID {
		return forbidden("internal handler attempt does not match the App notification delivery")
	}
	delivery, err := txServiceActor.LoadEventDeliveryForUpdate(ctx, command.WorkspaceID, command.CurrentInternalDeliveryID)
	if err != nil {
		return err
	}
	if delivery.ID != command.CurrentInternalDeliveryID || attempt.ID != command.CurrentInternalAttemptID || attempt.ServiceEventDeliveryID != delivery.ID ||
		delivery.WorkspaceID != notification.WorkspaceID || delivery.ServiceDefinitionID != notification.ServiceDefinitionID ||
		delivery.ServiceInstallationID != notification.ServiceInstallationID || delivery.ServiceMemberID != notification.ServiceMemberID ||
		attempt.WorkspaceID != delivery.WorkspaceID || attempt.ServiceInstallationID != delivery.ServiceInstallationID || attempt.ServiceMemberID != delivery.ServiceMemberID ||
		delivery.ServiceKind != serviceactor.KindApp || delivery.SubscriptionKind != serviceactor.SubscriptionKindAppNotificationAction ||
		delivery.ExternalResourceType != appNotificationExternalResource || delivery.ExternalResourceKey != notification.ID ||
		delivery.DeliveryPolicy != serviceactor.AppRuntimeDeliveryPolicyInternalHandler || delivery.ServiceConnectionRef != handlerRef ||
		attempt.TransportKind != serviceactor.RuntimeTransportKindInternalHandler || attempt.ServiceRuntimeCredentialID != "" ||
		attempt.ServiceRuntimeSessionID != "" || attempt.DeliveryTokenHash != "" || attempt.DeliveryTokenExpiresAt != nil || attempt.CallbackURLSnapshot != "" {
		return forbidden("internal handler delivery cannot mutate this App notification")
	}
	if !appRuntimeDeliveryStatusAcceptsRuntimeAction(delivery.Status) ||
		(attempt.Status != serviceactor.DeliveryAttemptStatusLeased && attempt.Status != serviceactor.DeliveryAttemptStatusDelivered) ||
		!appRuntimeDeliveryLeaseMatchesAttempt(delivery, attempt) || attempt.LeaseExpiresAt == nil || !attempt.LeaseExpiresAt.After(now.UTC()) {
		return conflict("internal handler delivery attempt is no longer current")
	}
	action, err := store.LoadAppNotificationActionInTx(ctx, tx, delivery.WorkspaceID, delivery.EventID, true)
	if err != nil {
		return err
	}
	if action.ServiceEventDeliveryID != delivery.ID || action.AppNotificationID != notification.ID || action.MessageID != notification.MessageID ||
		(action.State != AppNotificationActionStateAccepted && action.State != AppNotificationActionStateDelivered) || action.CompletedAt != nil {
		return conflict("internal handler delivery action is no longer active")
	}
	if attempt.Status == serviceactor.DeliveryAttemptStatusLeased {
		if _, err := txServiceActor.MarkDeliveryAttemptDelivered(ctx, serviceactor.DeliveryAttemptDeliveredMark{
			WorkspaceID: delivery.WorkspaceID, AttemptID: attempt.ID, Now: now,
		}); err != nil {
			return err
		}
	}
	if _, err := store.MarkAppNotificationActionDeliveredInTx(ctx, tx, delivery.WorkspaceID, action.ID, now); err != nil {
		return err
	}
	return nil
}

func loadAppNotificationMessageEditRevision(ctx context.Context, queryer coredb.QueryRower, notification AppNotificationRecord) (int64, error) {
	var revision int64
	if err := queryer.QueryRow(ctx, `
SELECT edit_revision FROM scope_messages
WHERE workspace_id = $1 AND scope_id = $2 AND id = $3
LIMIT 1
`, notification.WorkspaceID, notification.ScopeID, notification.MessageID).Scan(&revision); err != nil {
		return 0, fmt.Errorf("load App notification message edit revision: %w", err)
	}
	return revision, nil
}

func (store *Store) closeAppNotificationActionSubscriptionsInTx(ctx context.Context, tx coredb.Tx, notification AppNotificationRecord, preserveDeliveryID string, now time.Time) error {
	subscriptions, err := store.serviceActorStore().InTx(tx).ListEventSubscriptions(ctx, serviceactor.EventSubscriptionListQuery{
		WorkspaceID: notification.WorkspaceID, ServiceInstallationID: notification.ServiceInstallationID,
		SubscriptionKind:    serviceactor.SubscriptionKindAppNotificationAction,
		ExternalResourceKey: notification.ID, Limit: 100,
	})
	if err != nil {
		return err
	}
	for _, subscription := range subscriptions {
		if subscription.RemovedAt != nil {
			continue
		}
		if err := store.cancelAppNotificationActionDeliveriesForSubscriptionInTx(ctx, tx, notification.WorkspaceID, subscription.ID, preserveDeliveryID, now); err != nil {
			return err
		}
		var stillActive int
		err := tx.QueryRow(ctx, `
SELECT 1
FROM service_event_subscriptions
WHERE workspace_id = $1
  AND id = $2
  AND removed_at IS NULL
LIMIT 1
`, notification.WorkspaceID, subscription.ID).Scan(&stillActive)
		if coredb.IsNotFound(err) {
			// Cancellation can retire a terminal notification's subscription.
			// Avoid attempting a second soft removal below.
			continue
		}
		if err != nil {
			return fmt.Errorf("check App notification action subscription after cancellation: %w", err)
		}
		if preserveDeliveryID != "" {
			var ownsPreservedDelivery int
			err := tx.QueryRow(ctx, `
SELECT 1
FROM service_event_deliveries
WHERE workspace_id = $1
  AND id = $2
  AND service_event_subscription_id = $3
LIMIT 1
`, notification.WorkspaceID, preserveDeliveryID, subscription.ID).Scan(&ownsPreservedDelivery)
			if err == nil {
				// The subscription remains claimable until the preserved action
				// delivery reaches a terminal ledger outcome. This makes a crash
				// between notification mutation and delivery completion recoverable.
				continue
			}
			if !coredb.IsNotFound(err) {
				return fmt.Errorf("check preserved App notification action delivery: %w", err)
			}
		}
		if _, err := store.serviceActorStore().InTx(tx).RemoveEventSubscription(ctx, notification.WorkspaceID, subscription.ID, now); err != nil {
			return err
		}
	}
	return nil
}

func (store *Store) cancelAppNotificationActionDeliveriesForSubscriptionInTx(ctx context.Context, tx coredb.Tx, workspaceID, subscriptionID, preserveDeliveryID string, now time.Time) error {
	rows, err := tx.Query(ctx, `
SELECT id, event_id
FROM service_event_deliveries
WHERE workspace_id = $1
  AND service_event_subscription_id = $2
  AND subscription_kind = $3
  AND ($4 = '' OR id <> $4)
  AND status IN ('running', 'pending', 'retry_waiting')
ORDER BY created_at ASC, id ASC
`, strings.TrimSpace(workspaceID), strings.TrimSpace(subscriptionID), serviceactor.SubscriptionKindAppNotificationAction, strings.TrimSpace(preserveDeliveryID))
	if err != nil {
		return fmt.Errorf("list pending App notification action deliveries: %w", err)
	}
	type pendingActionDelivery struct {
		deliveryID string
		actionID   string
	}
	pending := make([]pendingActionDelivery, 0)
	for rows.Next() {
		var item pendingActionDelivery
		if err := rows.Scan(&item.deliveryID, &item.actionID); err != nil {
			rows.Close()
			return fmt.Errorf("scan pending App notification action delivery: %w", err)
		}
		pending = append(pending, item)
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		return fmt.Errorf("iterate pending App notification action deliveries: %w", err)
	}
	rows.Close()
	for _, item := range pending {
		changed, err := store.cancelAppRuntimeDeliveryInTx(ctx, tx, workspaceID, item.deliveryID, "App notification action is no longer active", now)
		if err != nil {
			return err
		}
		if !changed {
			continue
		}
		for _, expected := range []string{AppNotificationActionStateAccepted, AppNotificationActionStateDelivered} {
			changed, err := store.CompleteAppNotificationActionInTx(ctx, tx, workspaceID, item.actionID, expected, appNotificationActionStateCancelled, "notification_action_closed", "", now, now)
			if err != nil {
				return err
			}
			if changed {
				break
			}
		}
	}
	return nil
}

// retireAppNotificationActionSubscriptionAfterDeliveryInTx removes the
// temporarily preserved subscription only after its current action delivery is
// terminal. Keeping it active until this point lets an expired lease be
// reclaimed after a process crash between the notification mutation and ack.
func (store *Store) retireAppNotificationActionSubscriptionAfterDeliveryInTx(ctx context.Context, tx coredb.Tx, delivery serviceactor.EventDelivery, now time.Time) error {
	if delivery.SubscriptionKind != serviceactor.SubscriptionKindAppNotificationAction || strings.TrimSpace(delivery.EventSubscriptionID) == "" {
		return nil
	}
	notification, err := store.LoadAppNotificationInTx(ctx, tx, delivery.WorkspaceID, delivery.ExternalResourceKey, false)
	if err != nil {
		return err
	}
	retire := notification.Status != AppNotificationStatusActive
	if !retire {
		card, err := store.LoadAppNotificationCardByNotificationInTx(ctx, tx, notification.ID, false)
		if err != nil {
			return err
		}
		retire = card.State != AppNotificationCardStateActive
		if !retire {
			var schema struct {
				Actions []appNotificationCardAction `json:"actions"`
			}
			if err := json.Unmarshal(card.ActionSchemaJSON, &schema); err != nil {
				return fmt.Errorf("decode App notification action schema during subscription retirement: %w", err)
			}
			retire = true
			for _, action := range schema.Actions {
				if strings.TrimSpace(action.Type) == "callback" {
					retire = false
					break
				}
			}
		}
	}
	if !retire {
		return nil
	}
	_, err = store.serviceActorStore().InTx(tx).RemoveEventSubscription(ctx, delivery.WorkspaceID, delivery.EventSubscriptionID, now)
	return err
}

func (store *Store) appendAppNotificationMutationEffectInTx(ctx context.Context, tx coredb.Tx, auth AppRuntimeDeliveryAuth, notification AppNotificationRecord, mutationID, mutationKind string, now time.Time) (serviceactor.DeliveryEffect, error) {
	effectKind := runeffect.EffectKindMessageUpdate
	switch mutationKind {
	case AppNotificationMutationResolve:
		effectKind = runeffect.EffectKindNotificationResolved
	case AppNotificationMutationWithdraw:
		effectKind = runeffect.EffectKindMessageWithdrawal
	case AppNotificationMutationUpdate:
	default:
		return serviceactor.DeliveryEffect{}, badRequest("App action delivery does not accept this notification mutation effect")
	}
	metadata, err := model.MarshalJSONObject("App notification mutation effect metadata", map[string]any{
		"mutationId": mutationID, "mutationKind": mutationKind, "notificationRevision": notification.Revision,
	})
	if err != nil {
		return serviceactor.DeliveryEffect{}, err
	}
	effectID := appNotificationMutationEffectID(auth.Attempt.ID, mutationID)
	if existing, found, err := loadAppRuntimeReplyEffectRow(tx.QueryRow(ctx, `
SELECT id, workspace_id, service_event_delivery_id, service_event_delivery_attempt_id,
       service_installation_id, service_member_id, effect_kind, effect_ref,
       target_kind, target_ref, COALESCE(message_id, ''), status, metadata,
       created_at, updated_at
FROM service_event_delivery_effects
WHERE workspace_id = $1
  AND id = $2
LIMIT 1
`, strings.TrimSpace(auth.Delivery.WorkspaceID), effectID)); err != nil {
		return serviceactor.DeliveryEffect{}, fmt.Errorf("load App notification mutation effect: %w", err)
	} else if found {
		if existing.DeliveryAttemptID != auth.Attempt.ID ||
			existing.EffectKind != string(effectKind) ||
			existing.EffectRef != notification.ID ||
			existing.MessageID != notification.MessageID ||
			!bytes.Equal(bytes.TrimSpace(existing.Metadata), bytes.TrimSpace(metadata)) {
			return serviceactor.DeliveryEffect{}, conflict("App notification mutation effect receipt does not match the mutation")
		}
		return existing, nil
	}
	return store.serviceActorStore().InTx(tx).AppendDeliveryEffect(ctx, serviceactor.DeliveryEffectAppend{
		ID:          effectID,
		WorkspaceID: auth.Delivery.WorkspaceID, DeliveryAttemptID: auth.Attempt.ID,
		EffectKind: string(effectKind), EffectRef: notification.ID,
		TargetKind: auth.Delivery.TargetKind, TargetRef: auth.Delivery.TargetRef,
		MessageID: notification.MessageID, Status: serviceactor.DeliveryEffectStatusCreated,
		Metadata: metadata, Now: now,
	})
}

func appNotificationMutationEffectID(attemptID, mutationID string) string {
	digest := sha256.Sum256([]byte("app-notification-mutation-effect\x00" + strings.TrimSpace(attemptID) + "\x00" + strings.TrimSpace(mutationID)))
	return "apprt:mutation-effect:v1:" + hex.EncodeToString(digest[:])
}

func (store *Store) appNotificationMutationAppMessageInTx(ctx context.Context, tx coredb.Tx, notification AppNotificationRecord, binding serviceactor.TargetBinding, installation serviceactor.Installation, serviceMember serviceactor.Member, record message.ScopeMessageRecord) (message.AppMessage, error) {
	target, err := loadServiceEventDeliveryTarget(ctx, tx, serviceEventDeliveryTargetBinding{
		ID: binding.ID, WorkspaceID: binding.WorkspaceID, TargetKind: binding.TargetKind, TargetRef: binding.TargetRef,
		DeliveryScopeID: binding.DeliveryScopeID, PermissionCheckScopeID: binding.PermissionCheckScopeID,
		AuthoritySubjectMemberID: binding.AuthoritySubjectMemberID, DeliveryContract: binding.DeliveryContract,
		ServiceDefinitionID: binding.ServiceDefinitionID, ServiceInstallationID: binding.ServiceInstallationID,
		ServiceMemberID: binding.ServiceMemberID, Status: binding.Status,
	})
	if err != nil {
		return message.AppMessage{}, err
	}
	metadata, err := message.DecodeMessageMetadata(record.Metadata)
	if err != nil {
		return message.AppMessage{}, err
	}
	appMessage := appMessageFromEditedRecord(appChannelIDForTarget(target), target, message.AuthorProfile{
		ActorType: message.AuthorActorTypeService, PrincipalID: serviceMember.MemberID, MemberID: serviceMember.MemberID,
		TargetID: serviceMember.Handle, DisplayName: serviceMember.DisplayName,
		ServiceKind: message.AuthorServiceKindApp,
	}, record, metadata)
	appMessage.Source = message.ContributionSource{Kind: message.ContributionSourceKindServiceDirectPost, RefType: message.ContributionSourceRefTypeMessage, RefID: record.ID}
	messages := []message.AppMessage{appMessage}
	if err := attachServiceAuthorProfilePresentationWithQueryer(ctx, tx, notification.WorkspaceID, messages); err != nil {
		return message.AppMessage{}, err
	}
	return messages[0], nil
}
