package privateagent

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/core/domain"
	"github.com/tidbcloud/AgentTeam/internal/core/identifiers"
	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/attachment"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
)

const scopeFanoutInlineDeliveryGrace = 5 * time.Second

// This file owns the privateagent-local ordinary conversation write boundary. It
// is not a legacy private-agent isolated-scope writer: callers pass an
// already-resolved conversation target, and the implementation writes
// canonical scope messages plus scope fanout outbox facts. Keep this boundary separate until durable
// author/routing principal separation, owner-private routing, freshness replay,
// attachment echoing, reply-thread mutations, active-task projection, and fanout
// plan construction have an equivalent shared message-domain writer.

type conversationMessageWriteOptions struct {
	AllowControlledBroadcast   bool
	MentionHints               []message.MessageMentionHint
	MessageType                string
	ExplicitRoutingPlan        *message.AgentRoutingPlan
	Metadata                   map[string]any
	Source                     message.ContributionSource
	RoutingPrincipalMemberID   string
	ClientMessageID            string
	IdempotencyPayloadDigest   string
	InterruptPolicy            string
	Freshness                  message.SendMessageFreshness
	NotifyPrincipalMemberID    string
	IdempotencySubjectMemberID string
	OwnerQuoteSourceMessageIDs []string
	ForceScopeID               string
	ForceMessageID             string
	ForceWorkspaceGlobalSeq    int64
	AllocateWorkspaceGlobalSeq bool
}

type conversationMessageWriteInput struct {
	AuthorMemberID      string
	AuthorType          domain.MemberType
	Content             string
	AttachmentIDs       []string
	CreatedAt           time.Time
	ConversationTarget  *message.ThreadAccess
	ConversationOptions conversationMessageWriteOptions
}

func (store *Store) writeConversationMessageResult(ctx context.Context, tx coredb.Tx, input conversationMessageWriteInput) (message.SendMessageResult, error) {
	if input.ConversationTarget == nil {
		return message.SendMessageResult{}, badRequest("conversation target is required")
	}
	createdAt := input.CreatedAt
	if !createdAt.IsZero() {
		createdAt = createdAt.UTC()
	}
	return store.writeScopeMessageToConversationResult(
		ctx,
		tx,
		input.AuthorMemberID,
		input.AuthorType,
		*input.ConversationTarget,
		input.Content,
		input.AttachmentIDs,
		createdAt,
		input.ConversationOptions,
	)
}

func (store *Store) writeScopeMessageToConversationResult(ctx context.Context, tx coredb.Tx, authorMemberID string, authorType domain.MemberType, target message.ThreadAccess, content string, attachmentIDs []string, createdAt time.Time, options conversationMessageWriteOptions) (message.SendMessageResult, error) {
	authority := newConversationMessageAuthority(authorMemberID, authorType, options)
	const maxMessageContentBytes = 32 * 1024
	if len(content) > maxMessageContentBytes {
		return message.SendMessageResult{}, badRequest(fmt.Sprintf("content is too large (max %d bytes)", maxMessageContentBytes))
	}
	normalizedSource, err := options.Source.NormalizeForWrite()
	if err != nil {
		return message.SendMessageResult{}, err
	}
	options.Source = normalizedSource

	encodedMetadata, normalizedMetadata, err := encodeMessageMetadata(options.Metadata)
	if err != nil {
		return message.SendMessageResult{}, err
	}

	messageType := normalizeAppMessageType(options.MessageType)
	interruptPolicy, ok := message.NormalizeAgentInterruptPolicy(options.InterruptPolicy)
	if !ok {
		return message.SendMessageResult{}, badRequest("unsupported interruptPolicy")
	}

	var scope message.ScopeRecord
	if forceScopeID := strings.TrimSpace(options.ForceScopeID); forceScopeID != "" {
		forcedScope, err := message.NewScopeRepositories(tx).Scopes.LoadScope(ctx, forceScopeID)
		if err != nil {
			return message.SendMessageResult{}, err
		}
		scope = forcedScope
	} else {
		var err error
		scope, err = message.EnsureScopeProjectionForThreadAccess(ctx, tx, target, authority.authorMemberID, createdAt)
		if err != nil {
			return message.SendMessageResult{}, err
		}
	}
	workspaceID := strings.TrimSpace(scope.WorkspaceID)
	if workspaceID == "" {
		return message.SendMessageResult{}, ErrNotFound
	}

	freshness := options.Freshness
	freshnessEnabled := freshness.Enabled()
	clientMessageID := strings.TrimSpace(options.ClientMessageID)
	idempotencySubjectMemberID := firstNonEmpty(options.IdempotencySubjectMemberID, authority.authorMemberID)
	messageCreateDigest := ""
	if clientMessageID != "" {
		messageCreateDigest = strings.TrimSpace(options.IdempotencyPayloadDigest)
		if messageCreateDigest == "" {
			messageCreateDigest, err = message.ComputeMessageCreatePayloadDigest(message.MessageCreateDigestInput{
				MessageType:   messageType,
				Content:       content,
				AttachmentIDs: normalizeNonEmptyStrings(attachmentIDs),
				SourceRefs:    ownerQuoteDigestSourceRefs(messageType, content, options.OwnerQuoteSourceMessageIDs),
				Metadata:      encodedMetadata,
			})
			if err != nil {
				return message.SendMessageResult{}, err
			}
		}
	}
	if freshnessEnabled {
		if clientMessageID == "" {
			return message.SendMessageResult{}, badRequest("clientMessageId is required for freshness send")
		}
		if freshness.SeenUpToSeq < 0 {
			return message.SendMessageResult{}, badRequest("freshness.seenUpToSeq must be non-negative")
		}
		if strings.TrimSpace(freshness.TargetKey) != scope.ID {
			return message.SendMessageResult{}, badRequest("freshness.targetKey does not match canonical target")
		}
	}

	if clientMessageID != "" {
		if existing, err := message.NewScopeRepositories(tx).Messages.LoadByClientMessageID(ctx, scope.ID, authority.authorMemberID, idempotencySubjectMemberID, clientMessageID); err == nil {
			if err := requireConversationMessageReplayDigest(existing, messageCreateDigest); err != nil {
				return message.SendMessageResult{}, err
			}
			delivery, err := store.scopeDeliveryForMessageRecord(ctx, tx, workspaceID, target, existing, authority.authorMemberID, authority.authorType, true)
			if err != nil {
				return message.SendMessageResult{}, err
			}
			delivery.InterruptPolicy = interruptPolicy
			delivery.Message.InterruptPolicy = interruptPolicy
			return sendResultFromDelivery(message.SendStateAlreadySent, scope.ID, clientMessageID, freshness.SeenUpToSeq, delivery), nil
		} else if !errors.Is(err, message.ErrNotFound) {
			return message.SendMessageResult{}, err
		}
	}

	routingPlan := message.AgentRoutingPlan{}
	if options.ExplicitRoutingPlan != nil {
		routingPlan = message.FinalizeAgentRoutingPlan(*options.ExplicitRoutingPlan)
	} else {
		var err error
		routingPlan, err = message.LoadScopeAgentRoutingPlan(ctx, tx, authority.routingPrincipalMemberID, target, content, options.AllowControlledBroadcast)
		if err != nil {
			return message.SendMessageResult{}, err
		}
		routingPlan = message.FinalizeAgentRoutingPlan(routingPlan)
	}

	mentionedHumans, err := message.LoadMentionedHumanRecipientsForTarget(
		ctx,
		tx,
		workspaceID,
		authority.authorMemberID,
		target,
		mentionRoutingContent(messageType, content),
		options.MentionHints,
	)
	if err != nil {
		return message.SendMessageResult{}, err
	}

	var scopeSeq int64
	if freshnessEnabled {
		allocation, err := message.AllocateNextScopeMessageSeqWithVisibleFreshness(ctx, tx, message.ScopeMessageSeqVisibleFreshnessRequest{
			ScopeID:             scope.ID,
			ViewerMemberID:      authority.authorMemberID,
			ViewerType:          authority.authorFreshnessViewerType(),
			SeenUpToSeq:         freshness.SeenUpToSeq,
			UseVisibleMissCheck: authority.authorType == domain.MemberTypeAgent,
		})
		if err != nil {
			return message.SendMessageResult{}, err
		}
		if allocation.State == message.SendStateHeld {
			if existing, err := message.NewScopeRepositories(tx).Messages.LoadByClientMessageID(ctx, scope.ID, authority.authorMemberID, idempotencySubjectMemberID, clientMessageID); err == nil {
				if err := requireConversationMessageReplayDigest(existing, messageCreateDigest); err != nil {
					return message.SendMessageResult{}, err
				}
				delivery, err := store.scopeDeliveryForMessageRecord(ctx, tx, workspaceID, target, existing, authority.authorMemberID, authority.authorType, true)
				if err != nil {
					return message.SendMessageResult{}, err
				}
				return sendResultFromDelivery(message.SendStateAlreadySent, scope.ID, clientMessageID, freshness.SeenUpToSeq, delivery), nil
			} else if !errors.Is(err, message.ErrNotFound) {
				return message.SendMessageResult{}, err
			}
			return message.SendMessageResult{
				State:                message.SendStateHeld,
				TargetKey:            scope.ID,
				ClientMessageID:      clientMessageID,
				SeenUpToSeq:          freshness.SeenUpToSeq,
				LatestSeq:            allocation.LatestSeq,
				NeedsRefreshUntilSeq: allocation.LatestSeq,
			}, nil
		}
		scopeSeq = allocation.Seq
	} else {
		var err error
		scopeSeq, err = message.AllocateNextScopeMessageSeq(ctx, tx, scope.ID)
		if err != nil {
			return message.SendMessageResult{}, err
		}
	}
	createdAt, err = monotonicScopeMessageCreatedAt(ctx, tx, scope.ID, createdAt)
	if err != nil {
		return message.SendMessageResult{}, err
	}
	compatWorkspaceGlobalSeq := scopeSeq
	if options.AllocateWorkspaceGlobalSeq {
		compatWorkspaceGlobalSeq, err = message.AllocateNextWorkspaceMessageSeq(ctx, tx, workspaceID)
		if err != nil {
			return message.SendMessageResult{}, err
		}
	} else if options.ForceWorkspaceGlobalSeq > 0 {
		compatWorkspaceGlobalSeq = options.ForceWorkspaceGlobalSeq
	}
	messageID := strings.TrimSpace(options.ForceMessageID)
	if messageID == "" {
		var err error
		messageID, err = identifiers.NewMessageID()
		if err != nil {
			return message.SendMessageResult{}, fmt.Errorf("generate scope message id: %w", err)
		}
	}
	options.Source = options.Source.WithDefaultMessageRefID(messageID)
	normalizedSource, err = options.Source.NormalizeForWrite()
	if err != nil {
		return message.SendMessageResult{}, err
	}
	options.Source = normalizedSource
	plan := buildConversationMessagePlan(conversationMessagePlanInput{
		WorkspaceID:                workspaceID,
		ScopeID:                    scope.ID,
		MessageID:                  messageID,
		Seq:                        scopeSeq,
		WorkspaceGlobalSeq:         compatWorkspaceGlobalSeq,
		AuthorMemberID:             authority.authorMemberID,
		AuthorType:                 authority.authorType,
		NotifyPrincipalMemberID:    firstNonEmpty(authority.notifyPrincipalMemberID, authority.authorMemberID),
		IdempotencySubjectMemberID: idempotencySubjectMemberID,
		ClientMessageID:            options.ClientMessageID,
		IdempotencyPayloadDigest:   messageCreateDigest,
		MessageType:                messageType,
		Content:                    content,
		Metadata:                   encodedMetadata,
		CreatedAt:                  createdAt,
		AttachmentIDs:              attachmentIDs,
		SourceMessageIDs:           ownerQuoteSourceMessageIDs(messageType, content, options.OwnerQuoteSourceMessageIDs),
	})

	applied, err := message.ApplyMessagePlanTxWithSidecarWriter(ctx, tx, plan, conversationMessageSidecarWriter{store: store})
	if err != nil {
		return message.SendMessageResult{}, err
	}
	if !applied.Message.Inserted {
		delivery, err := store.scopeDeliveryForMessageRecord(ctx, tx, workspaceID, target, applied.Message.Message, authority.authorMemberID, authority.authorType, true)
		if err != nil {
			return message.SendMessageResult{}, err
		}
		delivery.InterruptPolicy = interruptPolicy
		delivery.Message.InterruptPolicy = interruptPolicy
		return sendResultFromDelivery(message.SendStateAlreadySent, scope.ID, clientMessageID, freshness.SeenUpToSeq, delivery), nil
	}
	attachments := applied.Attachments

	authorPublicName := strings.TrimSpace(authority.authorMemberID)
	if authority.authorType == domain.MemberTypeAgent || authority.authorType == domain.MemberTypeHuman {
		publicNames, err := store.loadPublicMemberNamesByMemberID(ctx, tx, workspaceID, []string{authority.authorMemberID})
		if err != nil {
			return message.SendMessageResult{}, err
		}
		authorPublicName = publicMemberNameForMemberID(authority.authorMemberID, publicNames)
	}

	receiveMessage := message.ReceiveMessage{
		MessageID:               applied.Message.Message.ID,
		TargetKey:               applied.Message.Message.ScopeID,
		TargetSeq:               applied.Message.Message.Seq,
		NotifyPrincipalMemberID: applied.Message.Message.NotifyPrincipalMemberID,
		AuthorProfile: message.AuthorProfile{
			ActorType:   string(authority.authorType),
			PrincipalID: authorPublicName,
			MemberID:    strings.TrimSpace(authority.authorMemberID),
			TargetID:    authorPublicName,
			DisplayName: authorPublicName,
		}.Normalize(),
		Source:          options.Source,
		MessageType:     messageType,
		Content:         content,
		Metadata:        copyMessageMetadata(normalizedMetadata),
		Timestamp:       createdAt,
		Attachments:     attachments,
		InterruptPolicy: interruptPolicy,
	}
	if target.ReplyThread != nil {
		receiveMessage.ChannelType = "thread"
		receiveMessage.ChannelName = "thread-" + target.ReplyThread.ShortID
		receiveMessage.ParentChannelType = string(target.ThreadType)
		if target.ThreadType == domain.ThreadTypeDM {
			receiveMessage.ParentChannelName = strings.TrimSpace(authority.authorMemberID)
			receiveMessage.ParentChannelDisplayName = authorPublicName
		} else {
			receiveMessage.ParentChannelName = target.ReplyThread.ParentChannelName
		}
	} else if target.ThreadType == domain.ThreadTypeDM {
		receiveMessage.ChannelType = string(domain.ThreadTypeDM)
		receiveMessage.ChannelName = strings.TrimSpace(authority.authorMemberID)
		receiveMessage.ChannelDisplayName = authorPublicName
	} else {
		receiveMessage.ChannelType = string(domain.ThreadTypeChannel)
		receiveMessage.ChannelName = target.ChannelName
	}
	activeTaskTarget, err := store.taskActiveTargetForMessageTx(ctx, tx, workspaceID, target, messageType)
	if err != nil {
		return message.SendMessageResult{}, err
	}
	receiveMessage.ActiveTaskTarget = activeTaskTarget

	delivery := message.MessageDelivery{
		ThreadID:                target.ThreadID,
		Seq:                     applied.Message.Message.Seq,
		GlobalSeq:               applied.Message.Message.WorkspaceGlobalSeq,
		MessageID:               applied.Message.Message.ID,
		ClientMessageID:         strings.TrimSpace(applied.Message.Message.ClientMessageID),
		NotifyPrincipalMemberID: applied.Message.Message.NotifyPrincipalMemberID,
		InterruptPolicy:         interruptPolicy,
		Message:                 receiveMessage,
		Recipients:              routingPlan.DurableRecipients,
		WakeRecipients:          routingPlan.WakeRecipients,
		MentionedHumans:         mentionedHumans,
		MentionContext:          messageMentionContextForTarget(target),
	}

	if target.ReplyThread != nil {
		if target.ThreadType == domain.ThreadTypeChannel {
			replyThreadParticipantIDs := make([]string, 0, len(routingPlan.DurableRecipients))
			for _, recipient := range routingPlan.DurableRecipients {
				agentID := strings.TrimSpace(recipient.AgentID)
				if agentID == "" {
					continue
				}
				replyThreadParticipantIDs = append(replyThreadParticipantIDs, agentID)
			}
			replyThreadParticipantIDs, err = message.FilterAgentIDsByScopeMembers(ctx, tx, target.ReplyThread.ParentChannelID, replyThreadParticipantIDs)
			if err != nil {
				return message.SendMessageResult{}, err
			}
			if err := addReplyThreadParticipantIDs(ctx, tx, target.ChannelID, target.ReplyThread.ParentMessageID, replyThreadParticipantIDs); err != nil {
				return message.SendMessageResult{}, err
			}
		}

		participantID := ""
		if authority.authorType != domain.MemberTypeService {
			var err error
			participantID, err = resolveReplyThreadParticipantID(ctx, tx, authority.authorMemberID, authority.authorType)
			if err != nil {
				return message.SendMessageResult{}, err
			}
			if participantID != "" && authority.authorType == domain.MemberTypeAgent && target.ThreadType == domain.ThreadTypeChannel {
				filteredParticipantIDs, err := message.FilterAgentIDsByScopeMembers(ctx, tx, target.ReplyThread.ParentChannelID, []string{participantID})
				if err != nil {
					return message.SendMessageResult{}, err
				}
				if len(filteredParticipantIDs) == 0 {
					participantID = ""
				}
			}
		}
		if err := store.ensureReplyThreadParentAuthorParticipant(ctx, tx, target.ChannelID, target.ReplyThread.ParentMessageID, createdAt); err != nil {
			return message.SendMessageResult{}, err
		}
		updated, err := bumpReplyThreadSummary(ctx, tx, target.ChannelID, target.ReplyThread.ParentMessageID, createdAt, participantID)
		if err != nil {
			return message.SendMessageResult{}, err
		}
		if err := store.syncReplyThreadSubscriptions(
			ctx,
			tx,
			authority.authorMemberID,
			authority.authorType,
			target.ChannelID,
			target.ReplyThread.ParentMessageID,
			humanMentionRecipientMemberIDs(mentionedHumans),
			agentRecipientMemberIDs(routingPlan.DurableRecipients),
			applied.Message.Message.Seq,
			applied.Message.Message.WorkspaceGlobalSeq,
			createdAt,
		); err != nil {
			return message.SendMessageResult{}, err
		}
		delivery.ReplyThreadUpdated = &updated
	}

	return sendResultFromDelivery(message.SendStateSent, scope.ID, clientMessageID, freshness.SeenUpToSeq, delivery), nil
}

type conversationMessagePlanInput struct {
	WorkspaceID                string
	ScopeID                    string
	MessageID                  string
	Seq                        int64
	WorkspaceGlobalSeq         int64
	AuthorMemberID             string
	AuthorType                 domain.MemberType
	NotifyPrincipalMemberID    string
	IdempotencySubjectMemberID string
	ClientMessageID            string
	IdempotencyPayloadDigest   string
	MessageType                string
	Content                    string
	Metadata                   []byte
	CreatedAt                  time.Time
	AttachmentIDs              []string
	SourceMessageIDs           []string
}

func ownerQuoteSourceMessageIDs(messageType string, content string, explicitSourceMessageIDs []string) []string {
	if message.ClassifyMessageSemantics(messageType, nil).RenderKind != message.MessageRenderOwnerQuote {
		return nil
	}
	if sourceMessageIDs := normalizeNonEmptyStrings(explicitSourceMessageIDs); len(sourceMessageIDs) > 0 {
		return sourceMessageIDs
	}
	return message.ParseOwnerQuoteSourceMessageIDs(content)
}

// buildConversationMessagePlan is the ordinary channel/DM/reply-thread
// construction point: it binds the visible scope message row to its default
// scope fanout outbox intent before ApplyMessagePlanTx writes durable facts.
func buildConversationMessagePlan(input conversationMessagePlanInput) message.MessagePlan {
	createdAt := input.CreatedAt
	if createdAt.IsZero() {
		createdAt = time.Now().UTC()
	} else {
		createdAt = createdAt.UTC()
	}
	workspaceID := strings.TrimSpace(input.WorkspaceID)
	scopeID := strings.TrimSpace(input.ScopeID)
	messageID := strings.TrimSpace(input.MessageID)
	plan := message.MessagePlan{
		MessageWrite: message.ScopeMessageRecord{
			ID:                         messageID,
			WorkspaceID:                workspaceID,
			ScopeID:                    scopeID,
			Seq:                        input.Seq,
			WorkspaceGlobalSeq:         input.WorkspaceGlobalSeq,
			AuthorMemberID:             strings.TrimSpace(input.AuthorMemberID),
			AuthorType:                 string(input.AuthorType),
			NotifyPrincipalMemberID:    strings.TrimSpace(input.NotifyPrincipalMemberID),
			IdempotencySubjectMemberID: strings.TrimSpace(input.IdempotencySubjectMemberID),
			ClientMessageID:            strings.TrimSpace(input.ClientMessageID),
			IdempotencyPayloadDigest:   strings.TrimSpace(input.IdempotencyPayloadDigest),
			MessageType:                strings.TrimSpace(input.MessageType),
			Content:                    input.Content,
			Metadata:                   append([]byte(nil), input.Metadata...),
			CreatedAt:                  createdAt,
			UpdatedAt:                  createdAt,
		},
		SideEffectProfile: message.MessagePlanSideEffectProfileScopeFanout,
		OutboxEvents: []message.ScopeFanoutOutboxRecord{
			message.BuildScopeMessageFanoutOutboxRecord(message.ScopeMessageFanoutOutboxRecordParams{
				WorkspaceID:   workspaceID,
				ScopeID:       scopeID,
				MessageID:     messageID,
				CreatedAt:     createdAt,
				NextAttemptAt: createdAt.Add(scopeFanoutInlineDeliveryGrace),
			}),
		},
	}
	if attachmentIDs := normalizeNonEmptyStrings(input.AttachmentIDs); len(attachmentIDs) > 0 {
		plan.SidecarWrites.AttachmentClaims = append(plan.SidecarWrites.AttachmentClaims, message.AttachmentClaimWrite{
			WorkspaceID:        workspaceID,
			ScopeID:            scopeID,
			UploadedByMemberID: strings.TrimSpace(input.AuthorMemberID),
			MessageID:          messageID,
			AttachmentIDs:      attachmentIDs,
		})
	}
	if sourceMessageIDs := normalizeNonEmptyStrings(input.SourceMessageIDs); len(sourceMessageIDs) > 0 {
		plan.SidecarWrites.AttachmentCopies = append(plan.SidecarWrites.AttachmentCopies, message.AttachmentCopyWrite{
			WorkspaceID:        workspaceID,
			ScopeID:            scopeID,
			UploadedByMemberID: strings.TrimSpace(input.AuthorMemberID),
			MessageID:          messageID,
			SourceMessageIDs:   sourceMessageIDs,
		})
	}
	return plan
}

func ownerQuoteDigestSourceRefs(messageType string, content string, explicitSourceMessageIDs []string) []message.SourceRef {
	sourceMessageIDs := ownerQuoteSourceMessageIDs(messageType, content, explicitSourceMessageIDs)
	if len(sourceMessageIDs) == 0 {
		return nil
	}
	refs := make([]message.SourceRef, 0, len(sourceMessageIDs))
	for _, sourceMessageID := range sourceMessageIDs {
		refs = append(refs, message.SourceRef{
			SourceMessageID: strings.TrimSpace(sourceMessageID),
			SourceKind:      message.MessageTypeOwnerQuote,
		})
	}
	return refs
}

func requireConversationMessageReplayDigest(existing message.ScopeMessageRecord, requestedDigest string) error {
	if strings.EqualFold(strings.TrimSpace(existing.IdempotencyPayloadDigest), strings.TrimSpace(requestedDigest)) {
		return nil
	}
	return conflict("clientMessageId was already used with a different message payload")
}

type conversationMessageSidecarWriter struct {
	store *Store
}

func (writer conversationMessageSidecarWriter) ClaimAttachmentsForMessage(ctx context.Context, tx coredb.Tx, claim message.AttachmentClaimWrite) ([]attachment.AppAttachment, error) {
	if writer.store == nil {
		return nil, fmt.Errorf("conversation sidecar writer store is required")
	}
	return writer.store.claimAttachmentsForMessage(
		ctx,
		tx,
		strings.TrimSpace(claim.WorkspaceID),
		strings.TrimSpace(claim.ScopeID),
		strings.TrimSpace(claim.UploadedByMemberID),
		strings.TrimSpace(claim.MessageID),
		claim.AttachmentIDs,
	)
}

func (writer conversationMessageSidecarWriter) CopyMessageAttachments(ctx context.Context, tx coredb.Tx, copyWrite message.AttachmentCopyWrite) ([]attachment.AppAttachment, error) {
	if writer.store == nil {
		return nil, fmt.Errorf("conversation sidecar writer store is required")
	}
	return writer.store.copyMessageAttachments(
		ctx,
		tx,
		strings.TrimSpace(copyWrite.WorkspaceID),
		strings.TrimSpace(copyWrite.ScopeID),
		strings.TrimSpace(copyWrite.UploadedByMemberID),
		strings.TrimSpace(copyWrite.MessageID),
		copyWrite.SourceMessageIDs,
	)
}

func sendResultFromDelivery(state string, targetKey string, clientMessageID string, seenUpToSeq int64, delivery message.MessageDelivery) message.SendMessageResult {
	acceptedSeq := delivery.Seq
	return message.SendMessageResult{
		State:           state,
		MessageID:       delivery.MessageID,
		TargetKey:       strings.TrimSpace(targetKey),
		ClientMessageID: strings.TrimSpace(firstNonEmpty(clientMessageID, delivery.ClientMessageID)),
		SeenUpToSeq:     seenUpToSeq,
		AcceptedSeq:     acceptedSeq,
		LatestSeq:       acceptedSeq,
		Delivery:        delivery,
	}
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		if trimmed := strings.TrimSpace(value); trimmed != "" {
			return trimmed
		}
	}
	return ""
}

func (store *Store) scopeDeliveryForMessageRecord(ctx context.Context, tx coredb.Tx, workspaceID string, target message.ThreadAccess, record message.ScopeMessageRecord, authorMemberID string, authorType domain.MemberType, replay bool) (message.MessageDelivery, error) {
	authorPublicName := strings.TrimSpace(authorMemberID)
	publicNames, err := store.loadPublicMemberNamesByMemberID(ctx, tx, workspaceID, []string{authorMemberID})
	if err != nil {
		return message.MessageDelivery{}, err
	}
	if name := publicMemberNameForMemberID(authorMemberID, publicNames); name != "" {
		authorPublicName = name
	}
	var metadata map[string]any
	if len(record.Metadata) > 0 {
		decoded, err := decodeMessageMetadata(record.Metadata)
		if err != nil {
			return message.MessageDelivery{}, err
		}
		metadata = decoded
	}
	attachments, err := loadPersistedAttachmentsForMessage(ctx, tx, workspaceID, record.ID)
	if err != nil {
		return message.MessageDelivery{}, err
	}
	receiveMessage := message.ReceiveMessage{
		MessageID:               record.ID,
		TargetKey:               record.ScopeID,
		TargetSeq:               record.Seq,
		NotifyPrincipalMemberID: record.NotifyPrincipalMemberID,
		AuthorProfile: message.AuthorProfile{
			ActorType:   string(authorType),
			PrincipalID: authorPublicName,
			MemberID:    strings.TrimSpace(authorMemberID),
			TargetID:    authorPublicName,
			DisplayName: authorPublicName,
		}.Normalize(),
		MessageType: record.MessageType,
		Content:     record.Content,
		Metadata:    copyMessageMetadata(metadata),
		Timestamp:   record.CreatedAt,
		Attachments: attachments,
	}
	return message.MessageDelivery{
		ThreadID:                target.ThreadID,
		Seq:                     record.Seq,
		GlobalSeq:               record.WorkspaceGlobalSeq,
		MessageID:               record.ID,
		ClientMessageID:         record.ClientMessageID,
		NotifyPrincipalMemberID: record.NotifyPrincipalMemberID,
		Message:                 receiveMessage,
		MentionContext:          messageMentionContextForTarget(target),
		IdempotentReplay:        replay,
	}, nil
}

func loadPersistedAttachmentsForMessage(ctx context.Context, queryer coredb.Queryer, workspaceID string, messageID string) ([]attachment.AppAttachment, error) {
	trimmedMessageID := strings.TrimSpace(messageID)
	if trimmedMessageID == "" {
		return nil, nil
	}
	query, args, err := attachment.BuildLoadAttachmentsForMessagesQuery(strings.TrimSpace(workspaceID), []string{trimmedMessageID})
	if err != nil {
		return nil, fmt.Errorf("load replay message attachments: %w", err)
	}
	rows, err := queryer.Query(ctx, query, args...)
	if err != nil {
		return nil, fmt.Errorf("load replay message attachments: %w", err)
	}
	defer rows.Close()

	attachments := make([]attachment.AppAttachment, 0)
	for rows.Next() {
		var rowMessageID string
		var item attachment.AppAttachment
		if err := rows.Scan(&rowMessageID, &item.ID, &item.Filename, &item.ContentType, &item.SizeBytes); err != nil {
			return nil, fmt.Errorf("scan replay message attachment: %w", err)
		}
		if rowMessageID == "" {
			continue
		}
		attachments = append(attachments, item)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate replay message attachments: %w", err)
	}
	return attachments, nil
}

func monotonicScopeMessageCreatedAt(ctx context.Context, tx coredb.Tx, scopeID string, requestedCreatedAt time.Time) (time.Time, error) {
	createdAt := requestedCreatedAt
	if createdAt.IsZero() {
		createdAt = time.Now().UTC()
	} else {
		createdAt = createdAt.UTC()
	}

	var latestMessageAt sql.NullTime
	if err := tx.QueryRow(ctx, `
SELECT latest_message_at
FROM scopes
WHERE id = $1
`, strings.TrimSpace(scopeID)).Scan(&latestMessageAt); err != nil {
		return time.Time{}, fmt.Errorf("load scope latest message time: %w", err)
	}
	if latestMessageAt.Valid {
		latest := latestMessageAt.Time.UTC()
		if createdAt.Before(latest) {
			return latest, nil
		}
	}
	return createdAt, nil
}

func scopeFreshnessViewerType(authorType domain.MemberType) string {
	if authorType == domain.MemberTypeAgent {
		return message.ScopeActorPrivateAgent
	}
	return message.ScopeActorHuman
}
