Ctrl K
ComunidadGuías…

internal/store/postgres/dialog.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package postgres

import (
	"context"
	"encoding/binary"
	"encoding/json"
	"errors"
	"fmt"
	"hash/fnv"

	"github.com/jackc/pgx/v5"

	"telesrv/internal/domain"
	"telesrv/internal/store/postgres/sqlcgen"
)

// DialogStore implements store.DialogStore with PostgreSQL.
type DialogStore struct {
	db sqlcgen.DBTX
	q  *sqlcgen.Queries
}

const dialogListSnapshotLimit = 10000

// NewDialogStore creates a DialogStore backed by a pgx pool (or transaction).
func NewDialogStore(db sqlcgen.DBTX) *DialogStore {
	return &DialogStore{db: db, q: sqlcgen.New(db)}
}

func (s *DialogStore) enrichDialogTopMessages(ctx context.Context, userID int64, messages []domain.Message) error {
	if len(messages) == 0 {
		return nil
	}
	return NewMessageStore(s.db).enrichPrivateMessageReactions(ctx, s.db, userID, messages)
}

func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
	return s.listByUser(ctx, userID, filter, 500)
}

func (s *DialogStore) listByUser(ctx context.Context, userID int64, filter domain.DialogFilter, maxLimit int) (domain.DialogList, error) {
	limit := filter.Limit
	if limit <= 0 {
		limit = 100
	}
	if maxLimit <= 0 {
		maxLimit = 500
	}
	if limit > maxLimit {
		limit = maxLimit
	}
	offsetPeerID := int64(0)
	if filter.HasOffsetPeer {
		offsetPeerID = filter.OffsetPeer.ID
	}
	folderParams := dialogFolderQueryParams(filter.Folder)
	summaryRows, err := s.q.ListDialogSummaryByUser(ctx, sqlcgen.ListDialogSummaryByUserParams{
		UserID:                 userID,
		HasFolderID:            filter.HasFolderID,
		FolderID:               pgInt32NonNegative(filter.FolderID),
		FolderExcludeArchived:  folderParams.excludeArchived,
		FolderExcludeRead:      folderParams.excludeRead,
		FolderExcludePeerTypes: folderParams.excludeTypes,
		FolderExcludePeerIds:   folderParams.excludeIDs,
		FolderIncludePeerTypes: folderParams.includeTypes,
		FolderIncludePeerIds:   folderParams.includeIDs,
		FolderPinnedPeerTypes:  folderParams.pinnedTypes,
		FolderPinnedPeerIds:    folderParams.pinnedIDs,
		FolderContacts:         folderParams.contacts,
		FolderNonContacts:      folderParams.nonContacts,
		PinnedOnly:             filter.PinnedOnly,
		ExcludePinned:          filter.ExcludePinned,
	})
	if err != nil {
		return domain.DialogList{}, fmt.Errorf("list dialog summary: %w", err)
	}
	summary := make([]domain.Dialog, 0, len(summaryRows))
	for _, row := range summaryRows {
		summary = append(summary, domain.Dialog{
			Peer:                  domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
			FolderID:              int(row.FolderID),
			TopMessage:            int(row.TopMessageID),
			TopMessageDate:        int(row.TopMessageDate),
			ReadInboxMaxID:        int(row.ReadInboxMaxID),
			ReadOutboxMaxID:       int(row.ReadOutboxMaxID),
			UnreadCount:           int(row.UnreadCount),
			UnreadMentions:        int(row.UnreadMentionsCount),
			UnreadReactions:       int(row.UnreadReactionsCount),
			TTLPeriod:             int(row.TtlPeriod),
			ThemeEmoticon:         row.ThemeEmoticon,
			HasScheduled:          row.HasScheduled,
			Pinned:                row.Pinned,
			PinnedOrder:           int(row.PinnedOrder),
			UnreadMark:            row.UnreadMark,
			PeerSettingsBarHidden: row.HiddenPeerSettingsBar,
		})
	}
	out := domain.DialogList{
		Dialogs: make([]domain.Dialog, 0, limit),
		Count:   len(summary),
		Hash:    dialogListHash(summary),
	}
	if len(summary) == 0 {
		return out, nil
	}
	rows, err := s.q.ListDialogsByUser(ctx, sqlcgen.ListDialogsByUserParams{
		UserID:                 userID,
		LimitCount:             int32(limit),
		HasFolderID:            filter.HasFolderID,
		FolderID:               pgInt32NonNegative(filter.FolderID),
		FolderExcludeArchived:  folderParams.excludeArchived,
		FolderExcludeRead:      folderParams.excludeRead,
		FolderExcludePeerTypes: folderParams.excludeTypes,
		FolderExcludePeerIds:   folderParams.excludeIDs,
		FolderIncludePeerTypes: folderParams.includeTypes,
		FolderIncludePeerIds:   folderParams.includeIDs,
		FolderPinnedPeerTypes:  folderParams.pinnedTypes,
		FolderPinnedPeerIds:    folderParams.pinnedIDs,
		FolderContacts:         folderParams.contacts,
		FolderNonContacts:      folderParams.nonContacts,
		PinnedOnly:             filter.PinnedOnly,
		ExcludePinned:          filter.ExcludePinned,
		OffsetID:               pgInt32NonNegative(filter.OffsetID),
		OffsetDate:             pgInt32NonNegative(filter.OffsetDate),
		HasOffsetPeer:          filter.HasOffsetPeer,
		OffsetPeerID:           offsetPeerID,
	})
	if err != nil {
		return domain.DialogList{}, fmt.Errorf("list dialogs: %w", err)
	}
	out.Messages = make([]domain.Message, 0, len(rows))
	out.Users = make([]domain.User, 0, len(rows))
	seenUsers := map[int64]struct{}{}
	for _, row := range rows {
		dialog := domain.Dialog{
			Peer: domain.Peer{
				Type: domain.PeerType(row.PeerType),
				ID:   row.PeerID,
			},
			FolderID:              int(row.FolderID),
			TopMessage:            int(row.TopMessageID),
			TopMessageDate:        int(row.TopMessageDate),
			ReadInboxMaxID:        int(row.ReadInboxMaxID),
			ReadOutboxMaxID:       int(row.ReadOutboxMaxID),
			UnreadCount:           int(row.UnreadCount),
			UnreadMentions:        int(row.UnreadMentionsCount),
			UnreadReactions:       int(row.UnreadReactionsCount),
			TTLPeriod:             int(row.TtlPeriod),
			ThemeEmoticon:         row.ThemeEmoticon,
			HasScheduled:          row.HasScheduled,
			Pinned:                row.Pinned,
			PinnedOrder:           int(row.PinnedOrder),
			UnreadMark:            row.UnreadMark,
			PeerSettingsBarHidden: row.HiddenPeerSettingsBar,
		}
		out.Dialogs = append(out.Dialogs, dialog)
		if row.PeerUserID != 0 {
			if _, ok := seenUsers[row.PeerUserID]; !ok {
				seenUsers[row.PeerUserID] = struct{}{}
				out.Users = append(out.Users, domain.User{
					ID:                    row.PeerUserID,
					AccessHash:            row.PeerAccessHash,
					Phone:                 row.PeerPhone,
					FirstName:             row.PeerFirstName,
					LastName:              row.PeerLastName,
					Username:              row.PeerUsername,
					CountryCode:           row.PeerCountryCode,
					Verified:              row.PeerVerified,
					Support:               row.PeerSupport,
					Bot:                   row.PeerIsBot,
					BotInfoVersion:        int(row.PeerBotInfoVersion),
					PremiumUntil:          int(row.PeerPremiumUntil),
					EmojiStatusDocumentID: row.PeerEmojiStatusDocumentID,
					EmojiStatusUntil:      int(row.PeerEmojiStatusUntil),
					LastSeenAt:            int(row.PeerLastSeenAt),
					Contact:               row.PeerContact,
					Mutual:                row.PeerMutual,
				})
			}
		}
		if row.MessageID != 0 {
			entities, err := decodeMessageEntities(row.MessageEntitiesJson)
			if err != nil {
				return domain.DialogList{}, fmt.Errorf("decode message entities: %w", err)
			}
			media, err := decodeMessageMedia(row.MessageMediaJson)
			if err != nil {
				return domain.DialogList{}, fmt.Errorf("decode message media: %w", err)
			}
			markup, err := decodeReplyMarkup(row.MessageReplyMarkupJson)
			if err != nil {
				return domain.DialogList{}, fmt.Errorf("decode message reply markup: %w", err)
			}
			rich, err := decodeRichMessage(row.MessageRichMessageJson)
			if err != nil {
				return domain.DialogList{}, fmt.Errorf("decode message rich message: %w", err)
			}
			// The top message must carry the full metadata: TDesktop caches getDialogs
			// messages first and does not overwrite them with later difference/getHistory,
			// so a missing field such as reply makes pinned/reply service messages render
			// permanently as "Deleted message".
			silent, noforwards, reply, forward, err := messageMetadataFromFields(
				row.MessageSilent,
				row.MessageNoforwards,
				row.MessageReplyToMsgID,
				row.MessageReplyToPeerType,
				row.MessageReplyToPeerID,
				row.MessageReplyToTopID,
				row.MessageReplyToStoryID,
				row.MessageQuoteText,
				row.MessageQuoteEntitiesJson,
				row.MessageQuoteOffset,
				row.MessageReplyExternalJson,
				row.MessageFwdFromPeerType,
				row.MessageFwdFromPeerID,
				row.MessageFwdFromName,
				row.MessageFwdDate,
				row.MessageFwdSavedFromPeerType,
				row.MessageFwdSavedFromPeerID,
				row.MessageFwdSavedFromMsgID,
			)
			if err != nil {
				return domain.DialogList{}, fmt.Errorf("decode message metadata: %w", err)
			}
			out.Messages = append(out.Messages, domain.Message{
				ID:             int(row.MessageID),
				UID:            row.MessagePrivateMessageID,
				OwnerUserID:    row.UserID,
				Peer:           dialog.Peer,
				From:           domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID},
				Date:           int(row.MessageDate),
				EditDate:       int(row.MessageEditDate),
				HideEdited:     row.MessageHideEdited,
				Out:            row.MessageOutgoing,
				Silent:         silent,
				NoForwards:     noforwards,
				Body:           row.MessageBody,
				Entities:       entities,
				ReplyTo:        reply,
				Forward:        forward,
				Media:          media,
				TTLPeriod:      int(row.MessageTtlPeriod),
				ExpiresAt:      int(row.MessageExpiresAt),
				MediaUnread:    row.MessageMediaUnread,
				ReactionUnread: row.MessageReactionUnread,
				ViaBotID:       row.MessageViaBotID,
				GroupedID:      row.MessageGroupedID,
				Effect:         row.MessageEffect,
				ReplyMarkup:    markup,
				RichMessage:    rich,
				Pinned:         row.MessagePinned,
				SavedPeer:      savedPeerFromFields(row.MessageSavedPeerType, row.MessageSavedPeerID),
			})
		}
	}
	if err := s.enrichDialogTopMessages(ctx, userID, out.Messages); err != nil {
		return domain.DialogList{}, err
	}
	return out, nil
}

// ListDialogSnapshotHeaders returns the complete bounded private-dialog owner
// index without hydrating peer users or top-message payloads. Page payloads are
// resolved later through the versioned per-peer read model.
func (s *DialogStore) ListDialogSnapshotHeaders(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
	folderParams := dialogFolderQueryParams(filter.Folder)
	rows, err := s.q.ListDialogSummaryByUser(ctx, sqlcgen.ListDialogSummaryByUserParams{
		UserID:                 userID,
		HasFolderID:            filter.HasFolderID,
		FolderID:               pgInt32NonNegative(filter.FolderID),
		FolderExcludeArchived:  folderParams.excludeArchived,
		FolderExcludeRead:      folderParams.excludeRead,
		FolderExcludePeerTypes: folderParams.excludeTypes,
		FolderExcludePeerIds:   folderParams.excludeIDs,
		FolderIncludePeerTypes: folderParams.includeTypes,
		FolderIncludePeerIds:   folderParams.includeIDs,
		FolderPinnedPeerTypes:  folderParams.pinnedTypes,
		FolderPinnedPeerIds:    folderParams.pinnedIDs,
		FolderContacts:         folderParams.contacts,
		FolderNonContacts:      folderParams.nonContacts,
		PinnedOnly:             filter.PinnedOnly,
		ExcludePinned:          filter.ExcludePinned,
	})
	if err != nil {
		return domain.DialogList{}, fmt.Errorf("list dialog snapshot headers: %w", err)
	}
	if len(rows) > dialogListSnapshotLimit {
		return domain.DialogList{}, fmt.Errorf("private dialog snapshot exceeds %d entries", dialogListSnapshotLimit)
	}
	dialogs := make([]domain.Dialog, 0, len(rows))
	peerTypes := make([]string, 0, len(rows))
	peerIDs := make([]int64, 0, len(rows))
	for _, row := range rows {
		peerTypes = append(peerTypes, row.PeerType)
		peerIDs = append(peerIDs, row.PeerID)
		dialogs = append(dialogs, domain.Dialog{
			Peer:                  domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
			FolderID:              int(row.FolderID),
			TopMessage:            int(row.TopMessageID),
			TopMessageDate:        int(row.TopMessageDate),
			ReadInboxMaxID:        int(row.ReadInboxMaxID),
			ReadOutboxMaxID:       int(row.ReadOutboxMaxID),
			UnreadCount:           int(row.UnreadCount),
			UnreadMentions:        int(row.UnreadMentionsCount),
			UnreadReactions:       int(row.UnreadReactionsCount),
			TTLPeriod:             int(row.TtlPeriod),
			ThemeEmoticon:         row.ThemeEmoticon,
			HasScheduled:          row.HasScheduled,
			Pinned:                row.Pinned,
			PinnedOrder:           int(row.PinnedOrder),
			UnreadMark:            row.UnreadMark,
			PeerSettingsBarHidden: row.HiddenPeerSettingsBar,
		})
	}
	var dependencyHash int64
	if len(peerIDs) > 0 {
		if err := s.db.QueryRow(ctx, `
WITH requested AS (
    SELECT peer_type, peer_id
    FROM unnest($2::text[], $3::bigint[]) AS peer(peer_type, peer_id)
)
SELECT COALESCE(bit_xor(v.hash), 0)::bigint
FROM requested peer
JOIN read_model_versions v
  ON v.model = 'dialog_light'
 AND v.owner_user_id = $1
 AND v.peer_type = peer.peer_type
 AND v.peer_id = peer.peer_id`, userID, peerTypes, peerIDs).Scan(&dependencyHash); err != nil {
			return domain.DialogList{}, fmt.Errorf("read private dialog snapshot dependency hash: %w", err)
		}
	}
	return domain.DialogList{
		Dialogs: dialogs,
		Count:   len(dialogs),
		Hash:    mixDialogListDependencyHash(dialogListHash(dialogs), dependencyHash),
	}, nil
}

// ListAllBuiltinDialogSnapshotHeaders loads one owner base across main and
// archive folders. Pinned/exclude-pinned/folder variants are derived by the app
// layer from this immutable base instead of repeating the owner scan.
func (s *DialogStore) ListAllBuiltinDialogSnapshotHeaders(ctx context.Context, userID int64) (domain.DialogList, error) {
	if userID == 0 {
		return domain.DialogList{}, nil
	}
	rows, err := s.db.Query(ctx, `
SELECT d.peer_type,
       d.peer_id,
       d.folder_id,
       d.top_message_id,
       d.top_message_date,
       d.read_inbox_max_id,
       d.read_outbox_max_id,
       d.unread_count,
       d.unread_mentions_count,
       d.unread_reactions_count,
       d.ttl_period,
       d.theme_emoticon,
       d.has_scheduled,
       d.pinned,
       d.pinned_order,
       d.unread_mark,
       d.hidden_peer_settings_bar
FROM dialogs AS d
WHERE d.user_id = $1
  AND d.folder_id IN (0, 1)
ORDER BY d.pinned DESC,
         CASE WHEN d.pinned THEN COALESCE(d.pinned_order, 0) ELSE 0 END DESC,
         d.top_message_date DESC,
         d.top_message_id DESC,
         d.peer_id DESC
LIMIT $2`, userID, dialogListSnapshotLimit+1)
	if err != nil {
		return domain.DialogList{}, fmt.Errorf("list all built-in private dialog snapshot headers: %w", err)
	}
	defer rows.Close()
	dialogs := make([]domain.Dialog, 0, 128)
	for rows.Next() {
		var dialog domain.Dialog
		var peerType string
		if err := rows.Scan(
			&peerType,
			&dialog.Peer.ID,
			&dialog.FolderID,
			&dialog.TopMessage,
			&dialog.TopMessageDate,
			&dialog.ReadInboxMaxID,
			&dialog.ReadOutboxMaxID,
			&dialog.UnreadCount,
			&dialog.UnreadMentions,
			&dialog.UnreadReactions,
			&dialog.TTLPeriod,
			&dialog.ThemeEmoticon,
			&dialog.HasScheduled,
			&dialog.Pinned,
			&dialog.PinnedOrder,
			&dialog.UnreadMark,
			&dialog.PeerSettingsBarHidden,
		); err != nil {
			return domain.DialogList{}, fmt.Errorf("scan all built-in private dialog snapshot headers: %w", err)
		}
		dialog.Peer.Type = domain.PeerType(peerType)
		dialogs = append(dialogs, dialog)
	}
	if err := rows.Err(); err != nil {
		return domain.DialogList{}, fmt.Errorf("list all built-in private dialog snapshot header rows: %w", err)
	}
	if len(dialogs) > dialogListSnapshotLimit {
		return domain.DialogList{}, fmt.Errorf("private dialog snapshot exceeds %d entries", dialogListSnapshotLimit)
	}
	return domain.DialogList{Dialogs: dialogs, Count: len(dialogs)}, nil
}

// ListPrivateDialogPeerIDs is the narrow presence-fanout read model. Presence
// needs only private peer IDs; routing it through GetDialogs would hydrate
// channels, top messages, drafts and viewer projections and can even omit
// private peers when the first page is channel-heavy.
func (s *DialogStore) ListPrivateDialogPeerIDs(ctx context.Context, userID int64, limit int) ([]int64, error) {
	if userID == 0 {
		return nil, nil
	}
	if limit <= 0 || limit > 4096 {
		limit = 4096
	}
	rows, err := s.db.Query(ctx, `
SELECT peer_id
FROM dialogs
WHERE user_id = $1
  AND peer_type = 'user'
  AND peer_id <> $1
ORDER BY top_message_date DESC, top_message_id DESC, peer_id DESC
LIMIT $2`, userID, limit)
	if err != nil {
		return nil, fmt.Errorf("list private dialog peer ids: %w", err)
	}
	defer rows.Close()
	ids := make([]int64, 0, minInt(limit, 128))
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		ids = append(ids, id)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return ids, nil
}

func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
	if len(peers) == 0 {
		return domain.DialogList{}, nil
	}
	peerTypes := make([]string, 0, len(peers))
	peerIDs := make([]int64, 0, len(peers))
	for _, peer := range peers {
		if peer.Type == "" || peer.ID == 0 {
			continue
		}
		peerTypes = append(peerTypes, string(peer.Type))
		peerIDs = append(peerIDs, peer.ID)
	}
	if len(peerTypes) == 0 {
		return domain.DialogList{}, nil
	}
	rows, err := s.q.ListDialogsByPeers(ctx, sqlcgen.ListDialogsByPeersParams{
		UserID:    userID,
		PeerTypes: peerTypes,
		PeerIds:   peerIDs,
	})
	if err != nil {
		return domain.DialogList{}, fmt.Errorf("list dialogs by peers: %w", err)
	}
	out := domain.DialogList{
		Dialogs:  make([]domain.Dialog, 0, len(rows)),
		Messages: make([]domain.Message, 0, len(rows)),
		Users:    make([]domain.User, 0, len(rows)),
	}
	seenUsers := map[int64]struct{}{}
	for _, row := range rows {
		dialog := domain.Dialog{
			Peer:                  domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
			FolderID:              int(row.FolderID),
			TopMessage:            int(row.TopMessageID),
			TopMessageDate:        int(row.TopMessageDate),
			ReadInboxMaxID:        int(row.ReadInboxMaxID),
			ReadOutboxMaxID:       int(row.ReadOutboxMaxID),
			UnreadCount:           int(row.UnreadCount),
			UnreadMentions:        int(row.UnreadMentionsCount),
			UnreadReactions:       int(row.UnreadReactionsCount),
			TTLPeriod:             int(row.TtlPeriod),
			ThemeEmoticon:         row.ThemeEmoticon,
			HasScheduled:          row.HasScheduled,
			Pinned:                row.Pinned,
			PinnedOrder:           int(row.PinnedOrder),
			UnreadMark:            row.UnreadMark,
			PeerSettingsBarHidden: row.HiddenPeerSettingsBar,
		}
		out.Dialogs = append(out.Dialogs, dialog)
		if row.PeerUserID != 0 {
			if _, ok := seenUsers[row.PeerUserID]; !ok {
				seenUsers[row.PeerUserID] = struct{}{}
				out.Users = append(out.Users, domain.User{
					ID:                    row.PeerUserID,
					AccessHash:            row.PeerAccessHash,
					Phone:                 row.PeerPhone,
					FirstName:             row.PeerFirstName,
					LastName:              row.PeerLastName,
					Username:              row.PeerUsername,
					CountryCode:           row.PeerCountryCode,
					Verified:              row.PeerVerified,
					Support:               row.PeerSupport,
					Bot:                   row.PeerIsBot,
					BotInfoVersion:        int(row.PeerBotInfoVersion),
					PremiumUntil:          int(row.PeerPremiumUntil),
					EmojiStatusDocumentID: row.PeerEmojiStatusDocumentID,
					EmojiStatusUntil:      int(row.PeerEmojiStatusUntil),
					LastSeenAt:            int(row.PeerLastSeenAt),
					Contact:               row.PeerContact,
					Mutual:                row.PeerMutual,
				})
			}
		}
		if row.MessageID != 0 {
			entities, err := decodeMessageEntities(row.MessageEntitiesJson)
			if err != nil {
				return domain.DialogList{}, fmt.Errorf("decode message entities: %w", err)
			}
			media, err := decodeMessageMedia(row.MessageMediaJson)
			if err != nil {
				return domain.DialogList{}, fmt.Errorf("decode message media: %w", err)
			}
			markup, err := decodeReplyMarkup(row.MessageReplyMarkupJson)
			if err != nil {
				return domain.DialogList{}, fmt.Errorf("decode message reply markup: %w", err)
			}
			rich, err := decodeRichMessage(row.MessageRichMessageJson)
			if err != nil {
				return domain.DialogList{}, fmt.Errorf("decode message rich message: %w", err)
			}
			// The top message must carry the full metadata: TDesktop caches getDialogs
			// messages first and does not overwrite them with later difference/getHistory,
			// so a missing field such as reply makes pinned/reply service messages render
			// permanently as "Deleted message".
			silent, noforwards, reply, forward, err := messageMetadataFromFields(
				row.MessageSilent,
				row.MessageNoforwards,
				row.MessageReplyToMsgID,
				row.MessageReplyToPeerType,
				row.MessageReplyToPeerID,
				row.MessageReplyToTopID,
				row.MessageReplyToStoryID,
				row.MessageQuoteText,
				row.MessageQuoteEntitiesJson,
				row.MessageQuoteOffset,
				row.MessageReplyExternalJson,
				row.MessageFwdFromPeerType,
				row.MessageFwdFromPeerID,
				row.MessageFwdFromName,
				row.MessageFwdDate,
				row.MessageFwdSavedFromPeerType,
				row.MessageFwdSavedFromPeerID,
				row.MessageFwdSavedFromMsgID,
			)
			if err != nil {
				return domain.DialogList{}, fmt.Errorf("decode message metadata: %w", err)
			}
			out.Messages = append(out.Messages, domain.Message{
				ID:             int(row.MessageID),
				UID:            row.MessagePrivateMessageID,
				OwnerUserID:    row.UserID,
				Peer:           dialog.Peer,
				From:           domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID},
				Date:           int(row.MessageDate),
				EditDate:       int(row.MessageEditDate),
				HideEdited:     row.MessageHideEdited,
				Out:            row.MessageOutgoing,
				Silent:         silent,
				NoForwards:     noforwards,
				Body:           row.MessageBody,
				Entities:       entities,
				ReplyTo:        reply,
				Forward:        forward,
				Media:          media,
				TTLPeriod:      int(row.MessageTtlPeriod),
				ExpiresAt:      int(row.MessageExpiresAt),
				MediaUnread:    row.MessageMediaUnread,
				ReactionUnread: row.MessageReactionUnread,
				ViaBotID:       row.MessageViaBotID,
				GroupedID:      row.MessageGroupedID,
				Effect:         row.MessageEffect,
				ReplyMarkup:    markup,
				RichMessage:    rich,
				Pinned:         row.MessagePinned,
				SavedPeer:      savedPeerFromFields(row.MessageSavedPeerType, row.MessageSavedPeerID),
			})
		}
	}
	if err := s.enrichDialogTopMessages(ctx, userID, out.Messages); err != nil {
		return domain.DialogList{}, err
	}
	out.Count = len(out.Dialogs)
	out.Hash = dialogListHash(out.Dialogs)
	return out, nil
}

func (s *DialogStore) Upsert(ctx context.Context, userID int64, dialog domain.Dialog) error {
	if err := s.q.UpsertDialog(ctx, sqlcgen.UpsertDialogParams{
		UserID:               userID,
		PeerType:             string(dialog.Peer.Type),
		PeerID:               dialog.Peer.ID,
		TopMessageID:         int32(dialog.TopMessage),
		TopMessageDate:       int32(dialog.TopMessageDate),
		ReadInboxMaxID:       int32(dialog.ReadInboxMaxID),
		ReadOutboxMaxID:      int32(dialog.ReadOutboxMaxID),
		UnreadCount:          int32(dialog.UnreadCount),
		UnreadMentionsCount:  int32(dialog.UnreadMentions),
		UnreadReactionsCount: int32(dialog.UnreadReactions),
		Pinned:               dialog.Pinned,
		UnreadMark:           dialog.UnreadMark,
	}); err != nil {
		return fmt.Errorf("upsert dialog: %w", err)
	}
	return nil
}

func (s *DialogStore) UpsertInbox(ctx context.Context, userID int64, dialog domain.Dialog) error {
	if err := s.q.UpsertInboxDialog(ctx, sqlcgen.UpsertInboxDialogParams{
		UserID:         userID,
		PeerType:       string(dialog.Peer.Type),
		PeerID:         dialog.Peer.ID,
		TopMessageID:   int32(dialog.TopMessage),
		TopMessageDate: int32(dialog.TopMessageDate),
	}); err != nil {
		return fmt.Errorf("upsert inbox dialog: %w", err)
	}
	return nil
}

func (s *DialogStore) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error {
	data, err := json.Marshal(draft)
	if err != nil {
		return fmt.Errorf("marshal dialog draft: %w", err)
	}
	if err := s.q.UpsertDialogDraft(ctx, sqlcgen.UpsertDialogDraftParams{
		UserID:       userID,
		PeerType:     string(draft.Peer.Type),
		PeerID:       draft.Peer.ID,
		TopMessageID: int32(draft.TopMessageID),
		Date:         int32(draft.Date),
		DraftJson:    data,
	}); err != nil {
		return fmt.Errorf("upsert dialog draft: %w", err)
	}
	return nil
}

func (s *DialogStore) GetDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (domain.DialogDraft, bool, error) {
	var data []byte
	err := s.db.QueryRow(ctx, `
SELECT draft
FROM dialog_drafts
WHERE user_id = $1 AND peer_type = $2 AND peer_id = $3 AND top_message_id = $4`,
		userID, string(peer.Type), peer.ID, int32(topMessageID)).Scan(&data)
	if errors.Is(err, pgx.ErrNoRows) {
		return domain.DialogDraft{}, false, nil
	}
	if err != nil {
		return domain.DialogDraft{}, false, fmt.Errorf("get dialog draft: %w", err)
	}
	var draft domain.DialogDraft
	if err := json.Unmarshal(data, &draft); err != nil {
		return domain.DialogDraft{}, false, fmt.Errorf("decode dialog draft: %w", err)
	}
	return draft, true, nil
}

func (s *DialogStore) DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) {
	changed, err := s.q.DeleteDialogDraft(ctx, sqlcgen.DeleteDialogDraftParams{
		UserID:       userID,
		PeerType:     string(peer.Type),
		PeerID:       peer.ID,
		TopMessageID: int32(topMessageID),
	})
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return false, nil
		}
		return false, fmt.Errorf("delete dialog draft: %w", err)
	}
	return changed, nil
}

func (s *DialogStore) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
	rows, err := s.q.ListDialogDrafts(ctx, sqlcgen.ListDialogDraftsParams{
		UserID:     userID,
		LimitCount: int32(clampDialogDraftLimit(limit)),
	})
	if err != nil {
		return nil, fmt.Errorf("list dialog drafts: %w", err)
	}
	return decodeDialogDrafts(rows)
}

func (s *DialogStore) ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
	rows, err := s.q.ClearDialogDrafts(ctx, sqlcgen.ClearDialogDraftsParams{
		UserID:     userID,
		LimitCount: int32(clampDialogDraftLimit(limit)),
	})
	if err != nil {
		return nil, fmt.Errorf("clear dialog drafts: %w", err)
	}
	return decodeDialogDrafts(rows)
}

func (s *DialogStore) MarkRead(ctx context.Context, userID int64, peer domain.Peer, maxID int) (domain.ReadHistoryResult, error) {
	row, err := s.q.MarkDialogRead(ctx, sqlcgen.MarkDialogReadParams{
		UserID:   userID,
		PeerType: string(peer.Type),
		PeerID:   peer.ID,
		MaxID:    pgInt32NonNegative(maxID),
	})
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.ReadHistoryResult{OwnerUserID: userID, Peer: peer, MaxID: maxID}, nil
		}
		return domain.ReadHistoryResult{}, fmt.Errorf("mark dialog read: %w", err)
	}
	return domain.ReadHistoryResult{
		OwnerUserID:      row.UserID,
		Peer:             domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
		MaxID:            int(row.ReadInboxMaxID),
		StillUnreadCount: int(row.UnreadCount),
		Changed:          row.Changed,
	}, nil
}

func (s *DialogStore) SetPinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, int, error) {
	row, err := s.q.SetDialogPinned(ctx, sqlcgen.SetDialogPinnedParams{
		UserID:   userID,
		PeerType: string(peer.Type),
		PeerID:   peer.ID,
		Pinned:   pinned,
	})
	if err != nil {
		return false, 0, fmt.Errorf("set dialog pinned: %w", err)
	}
	return row.Changed, int(row.FolderID), nil
}

func (s *DialogStore) ReorderPinned(ctx context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) {
	peerTypes, peerIDs := peerArrays(order)
	changed := false
	if force {
		tag, err := s.db.Exec(ctx, `
WITH requested AS (
    SELECT ($3::text[])[i] AS peer_type, ($4::bigint[])[i] AS peer_id
    FROM generate_subscripts($4::bigint[], 1) AS g(i)
    WHERE i <= cardinality($3::text[])
)
UPDATE dialogs d
SET pinned = false, pinned_order = 0, updated_at = now()
WHERE d.user_id = $1
  AND d.pinned
  AND d.folder_id = $2::int
  AND NOT EXISTS (
      SELECT 1
      FROM requested r
      WHERE r.peer_type = d.peer_type
        AND r.peer_id = d.peer_id
  )`, userID, folderID, peerTypes, peerIDs)
		if err != nil {
			return false, fmt.Errorf("clear pinned dialogs not in order: %w", err)
		}
		if tag.RowsAffected() > 0 {
			changed = true
		}
	}
	if len(peerTypes) == 0 {
		return changed, nil
	}
	tag, err := s.db.Exec(ctx, `
WITH requested AS (
    SELECT ($3::text[])[i] AS peer_type, ($4::bigint[])[i] AS peer_id, i::int AS pos
    FROM generate_subscripts($4::bigint[], 1) AS g(i)
    WHERE i <= cardinality($3::text[])
),
deduped AS (
    SELECT DISTINCT ON (peer_type, peer_id)
        peer_type,
        peer_id,
        (cardinality($4::bigint[]) - pos + 1)::int AS ord
    FROM requested
    ORDER BY peer_type, peer_id, pos
)
UPDATE dialogs d
SET pinned = true, pinned_order = deduped.ord, updated_at = now()
FROM deduped
WHERE d.user_id = $1
  AND d.peer_type = deduped.peer_type
  AND d.peer_id = deduped.peer_id
  AND d.folder_id = $2::int
  AND (NOT d.pinned OR d.pinned_order IS DISTINCT FROM deduped.ord)`, userID, folderID, peerTypes, peerIDs)
	if err != nil {
		return false, fmt.Errorf("reorder pinned dialogs: %w", err)
	}
	if tag.RowsAffected() > 0 {
		changed = true
	}
	return changed, nil
}

func (s *DialogStore) SetUnreadMark(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) {
	changed, err := s.q.SetDialogUnreadMark(ctx, sqlcgen.SetDialogUnreadMarkParams{
		UserID:   userID,
		PeerType: string(peer.Type),
		PeerID:   peer.ID,
		Unread:   unread,
	})
	if err != nil {
		return false, fmt.Errorf("set dialog unread mark: %w", err)
	}
	return changed, nil
}

func (s *DialogStore) ListUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error) {
	rows, err := s.q.ListDialogUnreadMarks(ctx, userID)
	if err != nil {
		return nil, fmt.Errorf("list dialog unread marks: %w", err)
	}
	out := make([]domain.Peer, 0, len(rows))
	for _, row := range rows {
		out = append(out, domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID})
	}
	return out, nil
}

func (s *DialogStore) ListDraftsByPeers(ctx context.Context, userID int64, peers []domain.Peer) ([]domain.DialogDraft, error) {
	peerTypes := make([]string, 0, len(peers))
	peerIDs := make([]int64, 0, len(peers))
	seen := make(map[domain.Peer]struct{}, len(peers))
	for _, peer := range peers {
		if peer.ID == 0 {
			continue
		}
		if _, ok := seen[peer]; ok {
			continue
		}
		seen[peer] = struct{}{}
		peerTypes = append(peerTypes, string(peer.Type))
		peerIDs = append(peerIDs, peer.ID)
	}
	if len(peerIDs) == 0 {
		return nil, nil
	}
	rows, err := s.q.ListDialogDraftsByPeers(ctx, sqlcgen.ListDialogDraftsByPeersParams{
		UserID:    userID,
		PeerTypes: peerTypes,
		PeerIds:   peerIDs,
	})
	if err != nil {
		return nil, fmt.Errorf("list dialog drafts by peers: %w", err)
	}
	return decodeDialogDrafts(rows)
}

func (s *DialogStore) SetChatTheme(ctx context.Context, userID int64, peer domain.Peer, emoticon string) (bool, error) {
	if userID == 0 || peer.Type == "" || peer.ID == 0 {
		return false, nil
	}
	var current string
	err := s.db.QueryRow(ctx, `
SELECT theme_emoticon
FROM dialogs
WHERE user_id = $1 AND peer_type = $2 AND peer_id = $3`,
		userID, string(peer.Type), peer.ID).Scan(&current)
	if errors.Is(err, pgx.ErrNoRows) {
		if emoticon == "" {
			return false, nil
		}
		if _, err := s.db.Exec(ctx, `
INSERT INTO dialogs (user_id, peer_type, peer_id, theme_emoticon)
VALUES ($1, $2, $3, $4)`,
			userID, string(peer.Type), peer.ID, emoticon); err != nil {
			return false, fmt.Errorf("insert dialog chat theme: %w", err)
		}
		return true, nil
	}
	if err != nil {
		return false, fmt.Errorf("get dialog chat theme: %w", err)
	}
	if current == emoticon {
		return false, nil
	}
	if _, err := s.db.Exec(ctx, `
UPDATE dialogs
SET theme_emoticon = $4, updated_at = now()
WHERE user_id = $1 AND peer_type = $2 AND peer_id = $3`,
		userID, string(peer.Type), peer.ID, emoticon); err != nil {
		return false, fmt.Errorf("update dialog chat theme: %w", err)
	}
	return true, nil
}

func (s *DialogStore) SetPeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
	changed, err := s.q.SetPeerSettingsBarHidden(ctx, sqlcgen.SetPeerSettingsBarHiddenParams{
		UserID:   userID,
		PeerType: string(peer.Type),
		PeerID:   peer.ID,
	})
	if err != nil {
		return false, fmt.Errorf("set peer settings bar hidden: %w", err)
	}
	return changed, nil
}

func (s *DialogStore) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
	hidden, err := s.q.GetPeerSettingsBarHidden(ctx, sqlcgen.GetPeerSettingsBarHiddenParams{
		UserID:   userID,
		PeerType: string(peer.Type),
		PeerID:   peer.ID,
	})
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return false, nil
		}
		return false, fmt.Errorf("get peer settings bar hidden: %w", err)
	}
	return hidden, nil
}

func (s *DialogStore) SetTranslationDisabled(ctx context.Context, userID int64, peer domain.Peer, disabled bool) (bool, error) {
	if !disabled {
		tag, err := s.db.Exec(ctx, `
DELETE FROM peer_translation_settings
WHERE user_id = $1 AND peer_type = $2 AND peer_id = $3`, userID, string(peer.Type), peer.ID)
		if err != nil {
			return false, fmt.Errorf("enable peer translations: %w", err)
		}
		return tag.RowsAffected() > 0, nil
	}
	var changed bool
	err := s.db.QueryRow(ctx, `
WITH changed AS (
  INSERT INTO peer_translation_settings (user_id, peer_type, peer_id, disabled)
  VALUES ($1, $2, $3, true)
  ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE
    SET disabled = true, updated_at = now()
    WHERE peer_translation_settings.disabled IS DISTINCT FROM true
  RETURNING true
)
SELECT EXISTS (SELECT 1 FROM changed)`,
		userID, string(peer.Type), peer.ID).Scan(&changed)
	if err != nil {
		return false, fmt.Errorf("set translation disabled: %w", err)
	}
	return changed, nil
}

func (s *DialogStore) TranslationDisabled(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
	var disabled bool
	err := s.db.QueryRow(ctx, `
SELECT disabled
FROM peer_translation_settings
WHERE user_id = $1 AND peer_type = $2 AND peer_id = $3`,
		userID, string(peer.Type), peer.ID).Scan(&disabled)
	if errors.Is(err, pgx.ErrNoRows) {
		return false, nil
	}
	if err != nil {
		return false, fmt.Errorf("get translation disabled: %w", err)
	}
	return disabled, nil
}

func (s *DialogStore) ListFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) {
	rows, err := s.q.ListDialogFolders(ctx, userID)
	if err != nil {
		return domain.DialogFolderList{}, fmt.Errorf("list dialog folders: %w", err)
	}
	tagsEnabled := false
	if enabled, err := s.q.GetDialogFolderTags(ctx, userID); err == nil {
		tagsEnabled = enabled
	} else if !errors.Is(err, pgx.ErrNoRows) {
		return domain.DialogFolderList{}, fmt.Errorf("get dialog folder tags: %w", err)
	}
	out := domain.DialogFolderList{
		TagsEnabled: tagsEnabled,
		Folders:     make([]domain.DialogFolder, 0, len(rows)),
	}
	for _, row := range rows {
		folder, err := decodeDialogFolder(row.FilterJson)
		if err != nil {
			return domain.DialogFolderList{}, fmt.Errorf("decode dialog folder %d: %w", row.FilterID, err)
		}
		folder.ID = int(row.FilterID)
		folder.IsChatlist = row.IsChatlist
		out.Folders = append(out.Folders, folder)
	}
	return out, nil
}

func (s *DialogStore) GetFolder(ctx context.Context, userID int64, folderID int) (domain.DialogFolder, bool, error) {
	row, err := s.q.GetDialogFolder(ctx, sqlcgen.GetDialogFolderParams{
		UserID:   userID,
		FilterID: pgInt32NonNegative(folderID),
	})
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.DialogFolder{}, false, nil
		}
		return domain.DialogFolder{}, false, fmt.Errorf("get dialog folder: %w", err)
	}
	folder, err := decodeDialogFolder(row.FilterJson)
	if err != nil {
		return domain.DialogFolder{}, false, fmt.Errorf("decode dialog folder: %w", err)
	}
	folder.ID = int(row.FilterID)
	folder.IsChatlist = row.IsChatlist
	return folder, true, nil
}

func (s *DialogStore) UpsertFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error {
	data, err := json.Marshal(folder)
	if err != nil {
		return fmt.Errorf("marshal dialog folder: %w", err)
	}
	if err := s.q.UpsertDialogFolder(ctx, sqlcgen.UpsertDialogFolderParams{
		UserID:     userID,
		FilterID:   pgInt32NonNegative(folder.ID),
		IsChatlist: folder.IsChatlist,
		FilterJson: data,
	}); err != nil {
		return fmt.Errorf("upsert dialog folder: %w", err)
	}
	return nil
}

func (s *DialogStore) DeleteFolder(ctx context.Context, userID int64, folderID int) error {
	if err := s.q.DeleteDialogFolder(ctx, sqlcgen.DeleteDialogFolderParams{
		UserID:   userID,
		FilterID: pgInt32NonNegative(folderID),
	}); err != nil {
		return fmt.Errorf("delete dialog folder: %w", err)
	}
	return nil
}

func (s *DialogStore) ReorderFolders(ctx context.Context, userID int64, order []int) error {
	if err := s.q.ReorderDialogFolders(ctx, sqlcgen.ReorderDialogFoldersParams{
		UserID:    userID,
		FilterIds: int32s(order),
	}); err != nil {
		return fmt.Errorf("reorder dialog folders: %w", err)
	}
	return nil
}

func (s *DialogStore) SetFolderTagsEnabled(ctx context.Context, userID int64, enabled bool) error {
	if err := s.q.SetDialogFolderTags(ctx, sqlcgen.SetDialogFolderTagsParams{
		UserID:      userID,
		TagsEnabled: enabled,
	}); err != nil {
		return fmt.Errorf("set dialog folder tags: %w", err)
	}
	return nil
}

func (s *DialogStore) EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error {
	peerTypes := make([]string, 0, len(peers))
	peerIDs := make([]int64, 0, len(peers))
	folderIDs := make([]int32, 0, len(peers))
	seen := make(map[domain.Peer]struct{}, len(peers))
	for _, item := range peers {
		if item.Peer.Type == "" || item.Peer.ID == 0 {
			continue
		}
		if _, ok := seen[item.Peer]; ok {
			continue
		}
		seen[item.Peer] = struct{}{}
		peerTypes = append(peerTypes, string(item.Peer.Type))
		peerIDs = append(peerIDs, item.Peer.ID)
		folderIDs = append(folderIDs, pgInt32NonNegative(item.FolderID))
	}
	if len(peerTypes) == 0 {
		return nil
	}
	if err := s.q.EditDialogPeerFolders(ctx, sqlcgen.EditDialogPeerFoldersParams{
		UserID:    userID,
		PeerTypes: peerTypes,
		PeerIds:   peerIDs,
		FolderIds: folderIDs,
	}); err != nil {
		return fmt.Errorf("edit dialog peer folders: %w", err)
	}
	return nil
}

func (s *DialogStore) SetArchivePinned(ctx context.Context, userID int64, pinned bool) (bool, error) {
	changed, err := s.q.SetDialogArchivePinned(ctx, sqlcgen.SetDialogArchivePinnedParams{
		UserID: userID,
		Pinned: pinned,
	})
	if err != nil {
		return false, fmt.Errorf("set dialog archive pinned: %w", err)
	}
	return changed, nil
}

func (s *DialogStore) ArchivePinned(ctx context.Context, userID int64) (bool, error) {
	pinned, err := s.q.GetDialogArchivePinned(ctx, userID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			// Officially the default archive folder row is in the pinned area.
			return true, nil
		}
		return false, fmt.Errorf("get dialog archive pinned: %w", err)
	}
	return pinned, nil
}

func (s *DialogStore) CountArchiveUnread(ctx context.Context, userID int64) (int, int, error) {
	row, err := s.q.CountArchiveUnreadDialogs(ctx, userID)
	if err != nil {
		return 0, 0, fmt.Errorf("count archive unread dialogs: %w", err)
	}
	return int(row.UnreadPeers), int(row.UnreadMessages), nil
}

type dialogFolderParams struct {
	contacts        bool
	nonContacts     bool
	excludeArchived bool
	excludeRead     bool
	includeTypes    []string
	includeIDs      []int64
	pinnedTypes     []string
	pinnedIDs       []int64
	excludeTypes    []string
	excludeIDs      []int64
}

func dialogFolderQueryParams(folder *domain.DialogFolder) dialogFolderParams {
	if folder == nil {
		return dialogFolderParams{}
	}
	includeTypes, includeIDs := folderPeerArrays(folder.IncludePeers)
	pinnedTypes, pinnedIDs := folderPeerArrays(folder.PinnedPeers)
	excludeTypes, excludeIDs := folderPeerArrays(folder.ExcludePeers)
	return dialogFolderParams{
		contacts:        folder.Contacts,
		nonContacts:     folder.NonContacts,
		excludeArchived: folder.ExcludeArchived,
		excludeRead:     folder.ExcludeRead,
		includeTypes:    includeTypes,
		includeIDs:      includeIDs,
		pinnedTypes:     pinnedTypes,
		pinnedIDs:       pinnedIDs,
		excludeTypes:    excludeTypes,
		excludeIDs:      excludeIDs,
	}
}

func folderPeerArrays(peers []domain.DialogFolderPeer) ([]string, []int64) {
	peerTypes := make([]string, 0, len(peers))
	peerIDs := make([]int64, 0, len(peers))
	seen := make(map[domain.Peer]struct{}, len(peers))
	for _, item := range peers {
		peer := item.Peer
		if peer.Type == "" || peer.ID == 0 {
			continue
		}
		if _, ok := seen[peer]; ok {
			continue
		}
		seen[peer] = struct{}{}
		peerTypes = append(peerTypes, string(peer.Type))
		peerIDs = append(peerIDs, peer.ID)
	}
	return peerTypes, peerIDs
}

func decodeDialogFolder(data string) (domain.DialogFolder, error) {
	if data == "" {
		return domain.DialogFolder{}, nil
	}
	var folder domain.DialogFolder
	if err := json.Unmarshal([]byte(data), &folder); err != nil {
		return domain.DialogFolder{}, err
	}
	return folder, nil
}

func decodeDialogDrafts(rows []string) ([]domain.DialogDraft, error) {
	out := make([]domain.DialogDraft, 0, len(rows))
	for _, row := range rows {
		draft, err := decodeDialogDraft(row)
		if err != nil {
			return nil, err
		}
		out = append(out, draft)
	}
	return out, nil
}

func decodeDialogDraft(data string) (domain.DialogDraft, error) {
	if data == "" {
		return domain.DialogDraft{}, nil
	}
	var draft domain.DialogDraft
	if err := json.Unmarshal([]byte(data), &draft); err != nil {
		return domain.DialogDraft{}, fmt.Errorf("decode dialog draft: %w", err)
	}
	if draft.Entities == nil {
		draft.Entities = []domain.MessageEntity{}
	}
	return draft, nil
}

func clampDialogDraftLimit(limit int) int {
	if limit <= 0 || limit > domain.MaxDialogDraftsPerUser {
		return domain.MaxDialogDraftsPerUser
	}
	return limit
}

func peerArrays(peers []domain.Peer) ([]string, []int64) {
	peerTypes := make([]string, 0, len(peers))
	peerIDs := make([]int64, 0, len(peers))
	seen := make(map[domain.Peer]struct{}, len(peers))
	for _, peer := range peers {
		if peer.Type == "" || peer.ID == 0 {
			continue
		}
		if _, ok := seen[peer]; ok {
			continue
		}
		seen[peer] = struct{}{}
		peerTypes = append(peerTypes, string(peer.Type))
		peerIDs = append(peerIDs, peer.ID)
	}
	return peerTypes, peerIDs
}

func dialogListHash(dialogs []domain.Dialog) int64 {
	if len(dialogs) == 0 {
		return 0
	}
	h := fnv.New64a()
	var buf [47]byte
	for _, d := range dialogs {
		binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID))
		binary.LittleEndian.PutUint32(buf[8:12], uint32(d.FolderID))
		binary.LittleEndian.PutUint32(buf[12:16], uint32(d.TopMessage))
		binary.LittleEndian.PutUint32(buf[16:20], uint32(d.TopMessageDate))
		binary.LittleEndian.PutUint32(buf[20:24], uint32(d.ReadInboxMaxID))
		binary.LittleEndian.PutUint32(buf[24:28], uint32(d.ReadOutboxMaxID))
		binary.LittleEndian.PutUint32(buf[28:32], uint32(d.UnreadCount))
		binary.LittleEndian.PutUint32(buf[32:36], uint32(d.UnreadMentions))
		binary.LittleEndian.PutUint32(buf[36:40], uint32(d.UnreadReactions))
		if d.Pinned {
			buf[40] = 1
		} else {
			buf[40] = 0
		}
		binary.LittleEndian.PutUint32(buf[41:45], uint32(d.PinnedOrder))
		if d.UnreadMark {
			buf[45] = 1
		} else {
			buf[45] = 0
		}
		if d.PeerSettingsBarHidden {
			buf[46] = 1
		} else {
			buf[46] = 0
		}
		_, _ = h.Write(buf[:])
		_, _ = h.Write([]byte(d.ThemeEmoticon))
	}
	return int64(h.Sum64())
}

// mixDialogListDependencyHash turns durable read-model version tokens into the
// list hash without forcing the owner ordering scan to derive every mutable
// dialog field. A token change is sufficient to reject an old client hash;
// the page itself is then hydrated from the exact per-peer projection.
func mixDialogListDependencyHash(base int64, dependencies ...int64) int64 {
	if base == 0 && len(dependencies) == 0 {
		return 0
	}
	h := fnv.New64a()
	var buf [8]byte
	binary.LittleEndian.PutUint64(buf[:], uint64(base))
	_, _ = h.Write(buf[:])
	for _, dependency := range dependencies {
		binary.LittleEndian.PutUint64(buf[:], uint64(dependency))
		_, _ = h.Write(buf[:])
	}
	sum := int64(h.Sum64() & 0x7fffffffffffffff)
	if sum == 0 {
		return 1
	}
	return sum
}