package app

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/core/domain"
	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/access"
	"github.com/tidbcloud/AgentTeam/internal/store/coordinator"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/model"
	"github.com/tidbcloud/AgentTeam/internal/store/taskboard"
)

type preparedTaskScopeHumanMessage struct {
	membership               access.HumanWorkspaceMember
	workspaceID              string
	taskScopeID              string
	content                  string
	idempotencyContent       string
	attachmentIDs            []string
	metadata                 map[string]any
	mentionHints             []taskboard.TaskScopeMessageMentionHint
	clientMessageID          string
	source                   message.ContributionSource
	requireCallbackResponder bool
	callbackResponderAgentID string
	callbackTaskWatchHandoff *message.WorkflowCallbackTaskWatchHandoff
	skipContentModeration    bool
}

func (store *Store) sendTaskScopeMessageForUser(ctx context.Context, user model.User, workspaceID string, taskScopeID string, content string, attachmentIDs []string, options SendAppMessageOptions) (message.AppMessage, message.MessageDelivery, error) {
	normalizedSource, err := options.Source.NormalizeForWrite()
	if err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, mapMessagingError(err)
	}
	options.Source = normalizedSource
	clientMessageID, err := normalizeClientMessageID(options.ClientMessageID)
	if err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, err
	}
	options.ClientMessageID = clientMessageID
	if clientMessageID != "" {
		replayed, replayDelivery, found, replayErr := store.loadWorkflowCallbackTaskScopeMessageReplay(
			ctx,
			user,
			workspaceID,
			taskScopeID,
			content,
			attachmentIDs,
			options,
		)
		if replayErr != nil {
			return message.AppMessage{}, message.MessageDelivery{}, replayErr
		}
		if found {
			return replayed, replayDelivery, nil
		}
	}

	var appMessage message.AppMessage
	var delivery message.MessageDelivery
	var moderationBlock *moderationBlockRecord
	var taskChange *taskboard.Task
	for attempt := 0; attempt < 2; attempt++ {
		err = coordinator.New(store.db).Run(ctx, "send task scope message workflow", func(tx coredb.Tx) error {
			prepared, prepareErr := store.prepareTaskScopeHumanMessageTx(ctx, tx, user, workspaceID, taskScopeID, content, attachmentIDs, options)
			if prepareErr != nil {
				return prepareErr
			}
			appMessage, delivery, moderationBlock, taskChange, prepareErr = store.appendTaskScopeHumanMessageTx(ctx, tx, user, prepared)
			return prepareErr
		})
		if err == nil {
			if taskChange != nil {
				store.emitTaskScopeMessageUpdated(ctx, taskboard.NewUserServiceContext(workspaceID, user.ID, user.DisplayName), *taskChange)
			}
			return appMessage, delivery, nil
		}
		if clientMessageID == "" || !uniqueViolationMatches(err, "scope_messages_client_message_id_key") || attempt > 0 {
			break
		}
	}
	if errors.Is(err, message.ErrContentModerationBlocked) && moderationBlock != nil {
		if insertErr := store.insertModerationBlock(ctx, store.db, *moderationBlock); insertErr != nil {
			return message.AppMessage{}, message.MessageDelivery{}, insertErr
		}
		return message.AppMessage{}, message.MessageDelivery{}, err
	}
	return message.AppMessage{}, message.MessageDelivery{}, mapTaskScopeMessageError(err)
}

func (store *Store) validateTaskScopeMessageForUser(ctx context.Context, user model.User, workspaceID string, taskScopeID string, content string, options SendAppMessageOptions, auditModerationBlock bool) error {
	normalizedSource, err := options.Source.NormalizeForWrite()
	if err != nil {
		return mapMessagingError(err)
	}
	options.Source = normalizedSource

	tx, err := store.db.BeginTx(ctx, coredb.TxOptions{})
	if err != nil {
		return fmt.Errorf("begin task message validation tx: %w", err)
	}
	var moderationBlock *moderationBlockRecord
	validationErr := func() error {
		prepared, err := store.prepareTaskScopeHumanMessageTx(ctx, tx, user, workspaceID, taskScopeID, content, nil, options)
		if err != nil {
			return err
		}
		_, _, moderationBlock, _, err = store.appendTaskScopeHumanMessageTx(ctx, tx, user, prepared)
		return err
	}()
	if rollbackErr := tx.Rollback(ctx); rollbackErr != nil {
		rollbackErr = fmt.Errorf("rollback task message validation tx: %w", rollbackErr)
		if validationErr != nil {
			return errors.Join(mapTaskScopeMessageError(validationErr), rollbackErr)
		}
		return rollbackErr
	}
	if auditModerationBlock && errors.Is(validationErr, message.ErrContentModerationBlocked) && moderationBlock != nil {
		if insertErr := store.insertModerationBlock(ctx, store.db, *moderationBlock); insertErr != nil {
			return insertErr
		}
	}
	return mapTaskScopeMessageError(validationErr)
}

func (store *Store) prepareTaskScopeHumanMessageTx(ctx context.Context, tx coredb.Tx, user model.User, workspaceID string, taskScopeID string, content string, attachmentIDs []string, options SendAppMessageOptions) (preparedTaskScopeHumanMessage, error) {
	if err := message.ValidateWorkflowCallbackResponderRouting(options.Metadata, options.AllowWorkflowContextMetadata, options.RequireSingleWorkflowCallbackResponder); err != nil {
		return preparedTaskScopeHumanMessage{}, badRequest(err.Error())
	}
	callbackResponderAgentRef := strings.TrimSpace(options.WorkflowCallbackResponderAgentRef)
	if err := message.ValidateWorkflowCallbackResponderAgentRef(
		options.Metadata,
		options.RequireSingleWorkflowCallbackResponder,
		callbackResponderAgentRef,
	); err != nil {
		return preparedTaskScopeHumanMessage{}, badRequest(err.Error())
	}
	if err := message.ValidateWorkflowContextMetadata(options.Metadata, options.AllowWorkflowContextMetadata); err != nil {
		return preparedTaskScopeHumanMessage{}, badRequest(err.Error())
	}
	callbackTaskWatchHandoff, err := message.NormalizeWorkflowCallbackTaskWatchHandoff(
		options.WorkflowCallbackTaskWatchHandoff,
		options.AllowWorkflowContextMetadata,
		options.RequireSingleWorkflowCallbackResponder,
		callbackResponderAgentRef,
	)
	if err != nil {
		return preparedTaskScopeHumanMessage{}, badRequest(err.Error())
	}
	workspaceID = strings.TrimSpace(workspaceID)
	taskScopeID = strings.TrimSpace(taskScopeID)
	if workspaceID == "" || taskScopeID == "" {
		return preparedTaskScopeHumanMessage{}, badRequest("workspaceId and taskScopeId are required")
	}
	if callbackTaskWatchHandoff != nil && callbackTaskWatchHandoff.TaskScopeID != taskScopeID {
		return preparedTaskScopeHumanMessage{}, badRequest("workflow callback Task watch handoff must match the Task discussion destination")
	}
	if options.StartAgentThread {
		return preparedTaskScopeHumanMessage{}, badRequest("task discussion messages cannot start agent threads")
	}
	if strings.TrimSpace(options.InterruptPolicy) != "" {
		return preparedTaskScopeHumanMessage{}, badRequest("task discussion messages do not support interruptPolicy")
	}
	if len(options.OwnerQuoteSourceMessageIDs) > 0 {
		return preparedTaskScopeHumanMessage{}, badRequest("task discussion messages do not support owner quote sources")
	}
	if len(options.ExplicitRecipients) > 0 || len(options.ExplicitWakeTargets) > 0 {
		return preparedTaskScopeHumanMessage{}, badRequest("task discussion routing is server-owned")
	}
	normalizedMessageType := message.NormalizeAppMessageType(options.MessageType)
	if strings.TrimSpace(options.MessageType) != "" && normalizedMessageType == "" {
		return preparedTaskScopeHumanMessage{}, badRequest("unsupported messageType")
	}
	if normalizedMessageType != "" && normalizedMessageType != message.MessageTypeTaskDiscussion {
		return preparedTaskScopeHumanMessage{}, badRequest("task scopes only accept task discussion messages")
	}
	normalizedAttachments, err := normalizeAttachmentIDs(attachmentIDs)
	if err != nil {
		return preparedTaskScopeHumanMessage{}, err
	}
	trimmedContent := strings.TrimSpace(content)
	if trimmedContent == "" && len(normalizedAttachments) == 0 {
		return preparedTaskScopeHumanMessage{}, badRequest("content is required")
	}
	clientMessageID, err := normalizeClientMessageID(options.ClientMessageID)
	if err != nil {
		return preparedTaskScopeHumanMessage{}, err
	}

	membership, err := store.loadTaskScopeHumanMessageAuthorTx(ctx, tx, user.ID, workspaceID, options)
	if err != nil {
		return preparedTaskScopeHumanMessage{}, err
	}

	trimmedContentForWrite := trimmedContent
	metadata := message.CopyMessageMetadata(options.Metadata)
	mentionHints := append([]message.MessageMentionHint(nil), options.MentionHints...)
	callbackResponderAgentID := ""
	if options.RequireSingleWorkflowCallbackResponder && callbackResponderAgentRef != "" && !options.skipWorkflowCallbackRouting {
		responder, resolveErr := resolveTaskScopeWorkflowCallbackResponder(
			ctx,
			tx,
			workspaceID,
			taskScopeID,
			callbackResponderAgentRef,
			membership.Member.ID,
		)
		if resolveErr != nil {
			return preparedTaskScopeHumanMessage{}, resolveErr
		}
		// P0 fence: resolved responder must equal the frozen Agent ID.
		if expected := strings.TrimSpace(options.WorkflowCallbackExpectedResponderAgentID); expected != "" && strings.TrimSpace(responder.AgentID) != expected {
			return preparedTaskScopeHumanMessage{}, conflict("workflow callback responder changed after freeze")
		}
		var responderHint message.MessageMentionHint
		trimmedContentForWrite, responderHint, resolveErr = materializeWorkflowCallbackResponderMention(trimmedContentForWrite, responder)
		if resolveErr != nil {
			return preparedTaskScopeHumanMessage{}, resolveErr
		}
		mentionHints = append(mentionHints, responderHint)
		if metadata == nil {
			metadata = make(map[string]any, 1)
		}
		if err := materializeWorkflowRecoveryResponderPresentation(metadata, responder); err != nil {
			return preparedTaskScopeHumanMessage{}, err
		}
		callbackResponderAgentID = strings.TrimSpace(responder.AgentID)
		metadata[message.WorkflowContextMetadataResponderAgentIDKey] = callbackResponderAgentID
	}

	prepared := preparedTaskScopeHumanMessage{
		membership:               membership,
		workspaceID:              workspaceID,
		taskScopeID:              taskScopeID,
		content:                  trimmedContentForWrite,
		idempotencyContent:       trimmedContent,
		attachmentIDs:            normalizedAttachments,
		metadata:                 metadata,
		mentionHints:             taskScopeMentionHints(mentionHints),
		clientMessageID:          clientMessageID,
		source:                   options.Source.Normalize(),
		requireCallbackResponder: options.RequireSingleWorkflowCallbackResponder,
		callbackResponderAgentID: callbackResponderAgentID,
		callbackTaskWatchHandoff: callbackTaskWatchHandoff,
	}
	prepared.skipContentModeration = options.SkipContentModeration
	return prepared, nil
}

func (store *Store) loadTaskScopeHumanMessageAuthorTx(ctx context.Context, tx coredb.Tx, userID string, workspaceID string, options SendAppMessageOptions) (access.HumanWorkspaceMember, error) {
	expectedAuthorMemberID := strings.TrimSpace(options.ExpectedAuthorMemberID)
	var membership access.HumanWorkspaceMember
	var err error
	if expectedAuthorMemberID != "" {
		membership, err = store.loadAuthorizedHumanWorkspaceMemberForUpdate(ctx, tx, userID, workspaceID, workspaceAccessAction)
		if err != nil {
			if errors.Is(err, ErrForbidden) {
				return access.HumanWorkspaceMember{}, conflict("workflow Run frozen author member is unavailable")
			}
			return access.HumanWorkspaceMember{}, err
		}
		if strings.TrimSpace(membership.Member.ID) != expectedAuthorMemberID {
			return access.HumanWorkspaceMember{}, conflict("workflow Run frozen author member is unavailable")
		}
	} else {
		membership, err = store.loadAuthorizedHumanWorkspaceMember(ctx, tx, userID, workspaceID, workspaceAccessAction)
		if err != nil {
			return access.HumanWorkspaceMember{}, err
		}
	}
	if principal := strings.TrimSpace(options.RoutingPrincipalMemberID); principal != "" && principal != strings.TrimSpace(membership.Member.ID) {
		return access.HumanWorkspaceMember{}, badRequest("task discussion routing principal must be the author")
	}
	return membership, nil
}

func (store *Store) appendTaskScopeHumanMessageTx(ctx context.Context, tx coredb.Tx, user model.User, prepared preparedTaskScopeHumanMessage) (message.AppMessage, message.MessageDelivery, *moderationBlockRecord, *taskboard.Task, error) {
	idempotencyPayloadDigest := ""
	if prepared.clientMessageID != "" {
		var err error
		idempotencyPayloadDigest, err = taskScopeHumanMessageCreateDigest(prepared)
		if err != nil {
			return message.AppMessage{}, message.MessageDelivery{}, nil, nil, err
		}
	}
	response, err := taskboard.New(store.db).InTx(tx).SendTaskScopeMessage(ctx, taskboard.SendTaskScopeMessageRequest{
		Context:     taskboard.NewUserServiceContext(prepared.workspaceID, user.ID, user.DisplayName),
		TaskScopeID: prepared.taskScopeID,
		Message: taskboard.TaskScopeMessageInput{
			Content:                  prepared.content,
			AttachmentIDs:            prepared.attachmentIDs,
			MentionHints:             prepared.mentionHints,
			Metadata:                 prepared.metadata,
			IdempotencyPayloadDigest: idempotencyPayloadDigest,
		},
		ClientMessageID: prepared.clientMessageID,
	})
	if err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, nil, nil, mapTaskScopeMessageError(err)
	}
	if response.State != message.SendStateSent && response.State != message.SendStateAlreadySent {
		return message.AppMessage{}, message.MessageDelivery{}, nil, nil, fmt.Errorf("task discussion message returned unsupported send state %q", response.State)
	}
	messageID := strings.TrimSpace(response.MessageID)
	if messageID == "" {
		messageID = strings.TrimSpace(response.Message.ID)
	}
	if messageID == "" {
		return message.AppMessage{}, message.MessageDelivery{}, nil, nil, fmt.Errorf("task discussion message returned no message id")
	}
	if response.State == message.SendStateSent {
		moderationBlock, err := store.taskScopeHumanMessageModerationBlock(ctx, user, prepared)
		if err != nil {
			return message.AppMessage{}, message.MessageDelivery{}, nil, nil, err
		}
		if moderationBlock != nil {
			return message.AppMessage{}, message.MessageDelivery{}, moderationBlock, nil, message.ContentModerationBlocked(contentModerationBlockedMessage)
		}
	}
	createdAt := response.Message.CreatedAt
	if err := store.bindScheduleRunMessageSource(ctx, tx, prepared.workspaceID, prepared.source, messageID, createdAt); err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, nil, nil, err
	}
	if err := store.bindWorkflowRunMessageSource(ctx, tx, prepared.workspaceID, prepared.source, messageID, prepared.clientMessageID, createdAt); err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, nil, nil, err
	}

	if response.State == message.SendStateAlreadySent {
		appMessage, delivery, err := loadTaskScopeMessageReplay(ctx, tx, prepared.workspaceID, prepared.taskScopeID, messageID)
		if err != nil {
			return message.AppMessage{}, message.MessageDelivery{}, nil, nil, err
		}
		if !prepared.source.IsZero() {
			appMessage.Source = prepared.source.Normalize()
			delivery.Message.Source = prepared.source.Normalize()
		}
		delivery.Recipients = nil
		delivery.WakeRecipients = nil
		delivery.IdempotentReplay = true
		delivery.AppMessage = &appMessage
		return appMessage, delivery, nil, nil, nil
	}

	appMessage, delivery, err := store.projectTaskScopeDiscussionDeliveryTx(ctx, tx, taskScopeDiscussionDeliveryParams{
		WorkspaceID:                            prepared.workspaceID,
		TaskScopeID:                            prepared.taskScopeID,
		MessageID:                              messageID,
		AuthorMemberID:                         prepared.membership.Member.ID,
		AuthorType:                             domain.MemberTypeHuman,
		AuthorPrincipalID:                      user.ID,
		AuthorTargetID:                         firstNonEmpty(user.Name, prepared.membership.Member.ID),
		AuthorDisplayName:                      firstNonEmpty(user.DisplayName, prepared.membership.Member.DisplayName, user.Name),
		Source:                                 prepared.source,
		RequireSingleWorkflowCallbackResponder: prepared.requireCallbackResponder,
		WorkflowCallbackResponderAgentID:       prepared.callbackResponderAgentID,
		WorkflowCallbackTaskWatchHandoff:       prepared.callbackTaskWatchHandoff,
	})
	if err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, nil, nil, err
	}
	return appMessage, delivery, nil, &response.Task, nil
}

func taskScopeHumanMessageCreateDigest(prepared preparedTaskScopeHumanMessage) (string, error) {
	metadata, _, err := message.EncodeMessageMetadata(message.MetadataWithoutDerivedWorkflowResponder(prepared.metadata))
	if err != nil {
		return "", err
	}
	return message.ComputeMessageCreatePayloadDigest(message.MessageCreateDigestInput{
		MessageType:   message.MessageTypeTaskDiscussion,
		Content:       prepared.idempotencyContent,
		AttachmentIDs: prepared.attachmentIDs,
		Metadata:      metadata,
	})
}

// loadWorkflowCallbackTaskScopeMessageReplay authenticates the frozen Human
// and immutable authored payload before resolving mutable responder routing.
// The original outbox remains the delivery receipt; replay never re-materializes
// a mention, changes Task watch state, or emits another synchronous wake.
func (store *Store) loadWorkflowCallbackTaskScopeMessageReplay(
	ctx context.Context,
	user model.User,
	workspaceID string,
	taskScopeID string,
	content string,
	attachmentIDs []string,
	options SendAppMessageOptions,
) (message.AppMessage, message.MessageDelivery, bool, error) {
	var replayed message.AppMessage
	var delivery message.MessageDelivery
	found := false
	err := coordinator.New(store.db).Run(ctx, "replay workflow callback task message", func(tx coredb.Tx) error {
		replayOptions := options
		replayOptions.skipWorkflowCallbackRouting = true
		prepared, err := store.prepareTaskScopeHumanMessageTx(
			ctx,
			tx,
			user,
			workspaceID,
			taskScopeID,
			content,
			attachmentIDs,
			replayOptions,
		)
		if err != nil {
			return err
		}
		if err := taskboard.New(store.db).InTx(tx).AuthorizeUserTaskScopeDiscussionWrite(
			ctx,
			user.ID,
			prepared.workspaceID,
			prepared.membership.Member.ID,
			prepared.taskScopeID,
		); err != nil {
			return mapTaskScopeMessageError(err)
		}
		record, err := message.NewScopeRepositories(tx).Messages.LoadByClientMessageID(
			ctx,
			prepared.taskScopeID,
			prepared.membership.Member.ID,
			prepared.membership.Member.ID,
			prepared.clientMessageID,
		)
		if errors.Is(err, message.ErrNotFound) {
			return nil
		}
		if err != nil {
			return err
		}
		requestedDigest, err := taskScopeHumanMessageCreateDigest(prepared)
		if err != nil {
			return err
		}
		if strings.TrimSpace(record.WorkspaceID) != prepared.workspaceID ||
			strings.TrimSpace(record.MessageType) != message.MessageTypeTaskDiscussion ||
			!strings.EqualFold(strings.TrimSpace(record.IdempotencyPayloadDigest), strings.TrimSpace(requestedDigest)) {
			return conflict("clientMessageId was already used with a different message payload")
		}
		replayed, delivery, err = loadTaskScopeMessageReplay(ctx, tx, prepared.workspaceID, prepared.taskScopeID, record.ID)
		if err != nil {
			return err
		}
		if err := store.bindScheduleRunMessageSource(ctx, tx, prepared.workspaceID, prepared.source, record.ID, record.CreatedAt); err != nil {
			return err
		}
		if err := store.bindWorkflowRunMessageSource(ctx, tx, prepared.workspaceID, prepared.source, record.ID, prepared.clientMessageID, record.CreatedAt); err != nil {
			return err
		}
		if !prepared.source.IsZero() {
			replayed.Source = prepared.source.Normalize()
			delivery.Message.Source = prepared.source.Normalize()
		}
		delivery.Recipients = nil
		delivery.WakeRecipients = nil
		delivery.IdempotentReplay = true
		delivery.AppMessage = &replayed
		found = true
		return nil
	})
	if err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, false, mapTaskScopeMessageError(err)
	}
	return replayed, delivery, found, nil
}

func (store *Store) taskScopeHumanMessageModerationBlock(ctx context.Context, user model.User, prepared preparedTaskScopeHumanMessage) (*moderationBlockRecord, error) {
	if prepared.skipContentModeration {
		return nil, nil
	}
	match, matched, err := store.matchEnabledModerationKeyword(ctx, prepared.content)
	if err != nil {
		return nil, err
	}
	if !matched {
		return nil, nil
	}
	return &moderationBlockRecord{
		WorkspaceID:    prepared.workspaceID,
		ScopeID:        prepared.taskScopeID,
		ScopeType:      "task",
		UserID:         strings.TrimSpace(user.ID),
		MemberID:       strings.TrimSpace(prepared.membership.Member.ID),
		Content:        prepared.content,
		MatchedKeyword: match.Keyword,
		CreatedAt:      time.Now().UTC(),
	}, nil
}

func loadTaskScopeMessageReplay(ctx context.Context, queryer coredb.QueryRower, workspaceID string, taskScopeID string, messageID string) (message.AppMessage, message.MessageDelivery, error) {
	var raw []byte
	if err := queryer.QueryRow(ctx, `
SELECT payload
FROM scope_fanout_outbox
WHERE workspace_id = $1
  AND scope_id = $2
  AND message_id = $3
  AND event_ref_type = $4
  AND fanout_type = $5
LIMIT 1
`, strings.TrimSpace(workspaceID), strings.TrimSpace(taskScopeID), strings.TrimSpace(messageID), message.DeliverySourceRefMessage, message.ScopeOutboxFanoutScope).Scan(&raw); err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, fmt.Errorf("load task discussion fanout receipt: %w", err)
	}
	payload, err := message.UnmarshalScopeFanoutPayload(raw)
	if err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, fmt.Errorf("decode task discussion fanout receipt: %w", err)
	}
	if strings.TrimSpace(payload.AppMessage.ID) != strings.TrimSpace(messageID) || strings.TrimSpace(payload.Delivery.MessageID) != strings.TrimSpace(messageID) {
		return message.AppMessage{}, message.MessageDelivery{}, fmt.Errorf("task discussion fanout receipt identity mismatch")
	}
	return payload.AppMessage, payload.Delivery, nil
}

func taskScopeMentionHints(hints []message.MessageMentionHint) []taskboard.TaskScopeMessageMentionHint {
	if len(hints) == 0 {
		return nil
	}
	converted := make([]taskboard.TaskScopeMessageMentionHint, 0, len(hints))
	for _, hint := range hints {
		converted = append(converted, taskboard.TaskScopeMessageMentionHint{
			Type: hint.Type, ID: hint.ID, Token: hint.Token, Metadata: hint.Metadata,
		})
	}
	return converted
}

func mapTaskScopeMessageError(err error) error {
	if err == nil {
		return nil
	}
	switch {
	case errors.Is(err, taskboard.ErrBadRequest):
		return badRequest(err.Error())
	case errors.Is(err, taskboard.ErrConflict):
		return conflict(err.Error())
	case errors.Is(err, taskboard.ErrForbidden):
		return ErrForbidden
	case errors.Is(err, taskboard.ErrNotFound):
		return ErrNotFound
	default:
		return mapMessagingError(err)
	}
}

// WriteMessageForUserInTx appends an ordinary Human message and its durable
// delivery/outbox payload to a caller-owned transaction. It deliberately shares
// prepareAppMessageForUserTx and appendMessageToThread with the normal Human
// writer, but never performs post-commit network delivery. Workflow approval
// completion uses this boundary so the terminal decision and comment either
// commit together or roll back together.
func (store *Store) WriteMessageForUserInTx(
	ctx context.Context,
	tx coredb.Tx,
	user model.User,
	workspaceID string,
	channelID string,
	content string,
	options message.HumanMessageCreateOptions,
) (message.AppMessage, message.MessageDelivery, error) {
	if store == nil || tx == nil {
		return message.AppMessage{}, message.MessageDelivery{}, fmt.Errorf("message write transaction is not configured")
	}
	normalizedSource, err := options.Source.NormalizeForWrite()
	if err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, mapMessagingError(err)
	}
	writeOptions := SendAppMessageOptions{
		MessageType:                              options.MessageType,
		Metadata:                                 options.Metadata,
		AllowWorkflowContextMetadata:             options.AllowWorkflowContextMetadata,
		RequireSingleWorkflowCallbackResponder:   options.RequireSingleWorkflowCallbackResponder,
		WorkflowCallbackResponderAgentRef:        options.WorkflowCallbackResponderAgentRef,
		WorkflowRunTeamID:                        options.WorkflowRunTeamID,
		WorkflowCallbackExpectedResponderAgentID: options.WorkflowCallbackExpectedResponderAgentID,
		WorkflowCallbackTaskWatchHandoff:         options.WorkflowCallbackTaskWatchHandoff,
		Source:                                   normalizedSource,
		MentionHints:                             options.MentionHints,
		ClientMessageID:                          options.ClientMessageID,
		ExpectedAuthorMemberID:                   options.ExpectedAuthorMemberID,
		StartAgentThread:                         options.StartAgentThread,
		InterruptPolicy:                          options.InterruptPolicy,
		OwnerQuoteSourceMessageIDs:               options.OwnerQuoteSourceMessageIDs,
	}
	clientMessageID, err := normalizeClientMessageID(writeOptions.ClientMessageID)
	if err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, err
	}
	writeOptions.ClientMessageID = clientMessageID
	if scopeType, err := workflowMessageScopeTypeTx(ctx, tx, workspaceID, channelID); err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, err
	} else if scopeType == "task" {
		appMessage, delivery, writeErr := writeWorkflowTaskMessageInTx(store, ctx, tx, user, workspaceID, channelID, content, writeOptions)
		if writeErr != nil || !options.RequireLiveHandoff {
			return appMessage, delivery, writeErr
		}
		delivery.RequireLiveHandoff = true
		delivery.Message.RequireLiveHandoff = true
		if persistErr := store.persistScopeFanoutPayloads(ctx, tx, delivery); persistErr != nil {
			return message.AppMessage{}, message.MessageDelivery{}, persistErr
		}
		return appMessage, delivery, nil
	}
	prepared, err := store.prepareAppMessageForUserTx(ctx, tx, user, workspaceID, channelID, content, nil, writeOptions)
	if err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, err
	}
	if clientMessageID != "" {
		replay, found, err := store.loadAppMessageReplayByClientMessageID(
			ctx, tx, workspaceID, message.ScopeIDForThreadAccess(prepared.target),
			appChannelIDForTarget(prepared.target), prepared.membership.Member.ID,
			prepared.membership.Member.ID, clientMessageID,
		)
		if err != nil {
			return message.AppMessage{}, message.MessageDelivery{}, err
		}
		if found {
			replayed, replayDelivery, replayErr := workflowApprovalMessageReplay(ctx, tx, workspaceID, prepared, writeOptions, replay)
			if replayErr != nil || !options.RequireLiveHandoff {
				return replayed, replayDelivery, replayErr
			}
			replayDelivery.RequireLiveHandoff = true
			replayDelivery.Message.RequireLiveHandoff = true
			if persistErr := store.persistScopeFanoutPayloads(ctx, tx, replayDelivery); persistErr != nil {
				return message.AppMessage{}, message.MessageDelivery{}, persistErr
			}
			return replayed, replayDelivery, nil
		}
	}
	if _, blocked, err := store.buildModerationBlockForHumanMessage(ctx, prepared, user.ID, workspaceID, false); err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, err
	} else if blocked {
		return message.AppMessage{}, message.MessageDelivery{}, message.ContentModerationBlocked(contentModerationBlockedMessage)
	}
	appMessage, delivery, writeErr := appendWorkflowApprovalMessageTx(store, ctx, tx, user, workspaceID, prepared, writeOptions, clientMessageID)
	if writeErr != nil || !options.RequireLiveHandoff {
		return appMessage, delivery, writeErr
	}
	delivery.RequireLiveHandoff = true
	delivery.Message.RequireLiveHandoff = true
	if persistErr := store.persistScopeFanoutPayloads(ctx, tx, delivery); persistErr != nil {
		return message.AppMessage{}, message.MessageDelivery{}, persistErr
	}
	return appMessage, delivery, nil
}
