/* eslint-disable @typescript-eslint/member-ordering */
import {
  Injectable,
  computed,
  effect,
  inject,
  signal
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { v4 as uuidv4 } from 'uuid';
import { DirectMessageRepository } from '../../infrastructure/direct-message.repository';
import { OfflineMessageQueueService } from './offline-message-queue.service';
import { PeerDeliveryService } from './peer-delivery.service';
import { AttachmentFacade } from '../../../attachment';
import { CustomEmojiService } from '../../../custom-emoji';
import {
  advanceDirectMessageStatus,
  createDirectConversation,
  createGroupConversation,
  createDirectCallStartedMessage,
  directMessageConversationIncludesUser,
  directMessageEventIncludesUser,
  directMessageSyncIncludesUser,
  getDirectConversationId,
  isGroupDirectConversation,
  updateMessageStatusInConversation,
  upsertDirectMessage
} from '../../domain/logic/direct-message.logic';
import {
  DirectMessage,
  DirectMessageConversation,
  DirectMessageEventPayload,
  DirectMessageMutationEventPayload,
  DirectMessageParticipant,
  DirectMessageSyncEventPayload,
  DirectMessageSyncRequestEventPayload,
  DirectMessageStatus,
  DirectMessageStatusEventPayload,
  DirectMessageTypingEventPayload,
  toDirectMessageParticipant
} from '../../domain/models/direct-message.model';
import type {
  ChatEvent,
  Reaction,
  User
} from '../../../../shared-kernel';
import { selectCurrentUser } from '../../../../store/users/users.selectors';

const DIRECT_MESSAGE_SYNC_LIMIT = 1000;
const DIRECT_MESSAGE_SYNC_REQUEST_COOLDOWN_MS = 5000;
const DIRECT_MESSAGE_TYPING_TTL_MS = 3000;
const DIRECT_MESSAGE_TYPING_PURGE_MS = 1000;
const DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX = 'direct-message:';

interface DirectMessageTypingEntry {
  conversationId: string;
  userId: string;
  displayName: string;
  expiresAt: number;
}

@Injectable({ providedIn: 'root' })
export class DirectMessageService {
  private readonly repository = inject(DirectMessageRepository);
  private readonly offlineQueue = inject(OfflineMessageQueueService);
  private readonly delivery = inject(PeerDeliveryService);
  private readonly attachments = inject(AttachmentFacade);
  private readonly customEmoji = inject(CustomEmojiService);
  private readonly store = inject(Store);
  private readonly router = inject(Router);
  private readonly currentUser = this.store.selectSignal(selectCurrentUser);
  private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
  private readonly selectedConversationIdSignal = signal<string | null>(null);
  private readonly typingEntriesSignal = signal<DirectMessageTypingEntry[]>([]);
  private readonly lastSyncRequestAt = new Map<string, number>();
  private loadedOwnerId: string | null = null;

  readonly conversations = computed(() => [...this.conversationsSignal()].sort(
    (firstConversation, secondConversation) => secondConversation.lastMessageAt - firstConversation.lastMessageAt
  ));
  readonly selectedConversationId = this.selectedConversationIdSignal.asReadonly();
  readonly selectedConversation = computed(() => {
    const selectedId = this.selectedConversationIdSignal();

    return selectedId
      ? this.conversationsSignal().find((conversation) => conversation.id === selectedId) ?? null
      : null;
  });
  readonly totalUnreadCount = computed(() => this.conversationsSignal().reduce(
    (total, conversation) => total + conversation.unreadCount,
    0
  ));
  readonly typingEntries = this.typingEntriesSignal.asReadonly();

  constructor() {
    effect(() => {
      const ownerId = this.getCurrentUserId();

      void this.loadForOwner(ownerId);
    });

    this.delivery.directMessageEvents$.subscribe((event) => {
      void this.handlePeerEvent(event);
    });

    this.delivery.peerConnected$.subscribe(() => {
      void this.retryPending();
      void this.requestOpenConversationSync();
    });

    this.delivery.networkRestored$.subscribe(() => {
      void this.retryPending();
      void this.requestOpenConversationSync();
    });

    window.setInterval(() => this.purgeExpiredTypingEntries(), DIRECT_MESSAGE_TYPING_PURGE_MS);
  }

  async createConversation(user: User): Promise<DirectMessageConversation> {
    const currentUser = this.requireCurrentUser();
    const ownerId = this.getCurrentUserIdOrThrow();

    await this.loadForOwner(ownerId);

    const currentParticipant = toDirectMessageParticipant(currentUser);
    const peerParticipant = toDirectMessageParticipant(user);
    const conversationId = getDirectConversationId(currentParticipant.userId, peerParticipant.userId);
    const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId);

    if (existingConversation) {
      this.selectedConversationIdSignal.set(existingConversation.id);
      return existingConversation;
    }

    const conversation = createDirectConversation(currentParticipant, peerParticipant, Date.now());

    await this.persistConversation(ownerId, conversation);
    this.selectedConversationIdSignal.set(conversation.id);
    return conversation;
  }

  async createGroupConversation(
    participants: DirectMessageParticipant[],
    title?: string,
    conversationId = `dm-group-${uuidv4()}`
  ): Promise<DirectMessageConversation> {
    const currentUser = this.requireCurrentUser();
    const ownerId = this.getCurrentUserIdOrThrow();
    const currentParticipant = toDirectMessageParticipant(currentUser);
    const allParticipants = this.uniqueParticipants([currentParticipant, ...participants]);

    await this.loadForOwner(ownerId);

    const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
      ?? await this.repository.getConversation(ownerId, conversationId);

    if (existingConversation) {
      const mergedConversation = this.mergeConversationParticipants({
        ...existingConversation,
        kind: 'group',
        title: existingConversation.title || title
      }, allParticipants);

      await this.persistConversation(ownerId, mergedConversation);
      this.selectedConversationIdSignal.set(mergedConversation.id);
      return mergedConversation;
    }

    const conversation = createGroupConversation(conversationId, allParticipants, Date.now(), title);

    await this.persistConversation(ownerId, conversation);
    this.selectedConversationIdSignal.set(conversation.id);
    return conversation;
  }

  async openConversation(conversationId: string): Promise<void> {
    const ownerId = this.getCurrentUserIdOrThrow();

    await this.loadForOwner(ownerId);
    this.selectedConversationIdSignal.set(conversationId);
    await this.markRead(conversationId);
    this.requestConversationSync(conversationId);
  }

  closeConversationView(conversationId?: string | null): void {
    if (!conversationId || this.selectedConversationIdSignal() === conversationId) {
      this.selectedConversationIdSignal.set(null);
    }
  }

  async forgetConversation(conversationId: string): Promise<void> {
    const ownerId = this.getCurrentUserIdOrThrow();
    const conversation = await this.repository.getConversation(ownerId, conversationId);

    if (!conversation) {
      return;
    }

    await this.repository.deleteConversation(ownerId, conversationId);

    for (const message of conversation.messages) {
      await this.offlineQueue.markDelivered(ownerId, message.id);
    }

    this.conversationsSignal.update((conversations) => conversations.filter((entry) => entry.id !== conversationId));

    if (this.selectedConversationIdSignal() === conversationId) {
      this.selectedConversationIdSignal.set(null);
    }
  }

  async sendMessage(conversationId: string, content: string, replyToId?: string): Promise<DirectMessage> {
    const normalizedContent = content.trim();

    if (!normalizedContent) {
      throw new Error('Cannot send an empty direct message.');
    }

    const currentUser = this.requireCurrentUser();
    const ownerId = this.getCurrentUserIdOrThrow();
    const conversation = await this.requireConversation(ownerId, conversationId);
    const senderId = currentUser.oderId || currentUser.id;
    const recipientIds = this.recipientIdsFor(conversation, senderId);
    const recipientId = recipientIds[0];

    if (!recipientId) {
      throw new Error('Direct message conversation has no recipients.');
    }

    const message: DirectMessage = {
      id: uuidv4(),
      conversationId,
      senderId,
      recipientId,
      recipientIds,
      content: normalizedContent,
      timestamp: Date.now(),
      status: 'QUEUED',
      reactions: [],
      isDeleted: false,
      replyToId
    };

    await this.persistConversation(ownerId, upsertDirectMessage(conversation, message, false));
    await this.attemptDelivery(ownerId, message, conversation);
    return message;
  }

  async recordCallStarted(
    conversationId: string,
    caller: DirectMessageParticipant,
    participants: DirectMessageParticipant[],
    timestamp = Date.now()
  ): Promise<void> {
    const ownerId = this.getCurrentUserIdOrThrow();
    const currentUser = this.requireCurrentUser();
    const currentParticipant = toDirectMessageParticipant(currentUser);
    const allParticipants = this.uniqueParticipants([
      currentParticipant,
      caller,
      ...participants
    ]);

    await this.loadForOwner(ownerId);

    const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
      ?? await this.repository.getConversation(ownerId, conversationId)
      ?? this.createConversationForSystemEvent(conversationId, currentParticipant, caller, allParticipants, timestamp);
    const conversation = this.mergeConversationParticipants(existingConversation, allParticipants);
    const message = createDirectCallStartedMessage(
      conversation.id,
      caller,
      conversation.participants.filter((participantId) => participantId !== caller.userId),
      timestamp
    );

    await this.persistConversation(ownerId, upsertDirectMessage(conversation, message, false));
  }

  async editMessage(conversationId: string, messageId: string, content: string): Promise<void> {
    const normalizedContent = content.trim();

    if (!normalizedContent) {
      return;
    }

    await this.applyAndSendMutation(conversationId, {
      conversationId,
      messageId,
      type: 'edit',
      content: normalizedContent,
      editedAt: Date.now(),
      updatedAt: Date.now()
    });
  }

  async deleteMessage(conversationId: string, messageId: string): Promise<void> {
    await this.applyAndSendMutation(conversationId, {
      conversationId,
      messageId,
      type: 'delete',
      updatedAt: Date.now()
    });
  }

  async addReaction(conversationId: string, messageId: string, emoji: string): Promise<void> {
    const userId = this.getCurrentUserIdOrThrow();
    const reaction: Reaction = {
      id: uuidv4(),
      messageId,
      oderId: userId,
      userId,
      emoji,
      timestamp: Date.now()
    };

    await this.applyAndSendMutation(conversationId, {
      conversationId,
      messageId,
      type: 'reaction-add',
      reaction,
      updatedAt: reaction.timestamp
    });
  }

  async toggleReaction(conversationId: string, messageId: string, emoji: string): Promise<void> {
    const userId = this.getCurrentUserIdOrThrow();
    const conversation = await this.requireConversation(userId, conversationId);
    const message = conversation.messages.find((entry) => entry.id === messageId);
    const existingReaction = message?.reactions?.find((reaction) =>
      reaction.emoji === emoji && (reaction.userId === userId || reaction.oderId === userId)
    );

    if (existingReaction) {
      await this.applyAndSendMutation(conversationId, {
        conversationId,
        messageId,
        type: 'reaction-remove',
        oderId: userId,
        emoji,
        updatedAt: Date.now()
      });

      return;
    }

    await this.addReaction(conversationId, messageId, emoji);
  }

  requestPeerAvatarSync(conversationId: string): void {
    const currentUserId = this.getCurrentUserId();
    const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);

    for (const peerId of this.recipientIdsFor(conversation, currentUserId)) {
      this.delivery.requestUserAvatar(peerId);
    }
  }

  currentUserId(): string | null {
    return this.getCurrentUserId();
  }

  async updateStatus(messageId: string, status: DirectMessageStatus): Promise<void> {
    const ownerId = this.getCurrentUserIdOrThrow();
    const conversation = this.conversationsSignal().find((entry) => entry.messages.some((message) => message.id === messageId));

    if (!conversation) {
      return;
    }

    await this.persistConversation(ownerId, updateMessageStatusInConversation(conversation, messageId, status));
  }

  async receiveMessage(message: DirectMessage, sender: User): Promise<void> {
    await this.handleIncomingMessage({
      message,
      sender: toDirectMessageParticipant(sender)
    });
  }

  async markRead(conversationId: string): Promise<void> {
    const ownerId = this.getCurrentUserIdOrThrow();
    const currentUserId = this.getCurrentUserIdOrThrow();
    const conversation = await this.requireConversation(ownerId, conversationId);
    const updatedConversation = { ...conversation, unreadCount: 0 };

    await this.persistConversation(ownerId, updatedConversation);
    await this.repository.markRead(ownerId, conversationId);

    for (const message of updatedConversation.messages) {
      if (message.recipientId !== currentUserId || message.status === 'ACKNOWLEDGED') {
        continue;
      }

      const nextStatus = advanceDirectMessageStatus(message.status, 'ACKNOWLEDGED');

      if (nextStatus !== message.status) {
        await this.persistConversation(ownerId, updateMessageStatusInConversation(updatedConversation, message.id, nextStatus));
      }

      this.sendStatusUpdate(message.senderId, {
        conversationId,
        messageId: message.id,
        status: 'ACKNOWLEDGED',
        updatedAt: Date.now()
      });
    }
  }

  async retryPending(): Promise<void> {
    const ownerId = this.getCurrentUserId();

    if (!ownerId) {
      return;
    }

    await this.loadForOwner(ownerId);

    const pendingMessageIds = await this.offlineQueue.retryPending(ownerId);
    const messages = this.conversationsSignal().flatMap((conversation) => conversation.messages);

    for (const messageId of pendingMessageIds) {
      const message = messages.find((entry) => entry.id === messageId);
      const conversation = message
        ? this.conversationsSignal().find((entry) => entry.id === message.conversationId)
        : null;

      if (message && conversation) {
        await this.attemptDelivery(ownerId, message, conversation);
      }
    }
  }

  typingUsers(conversationId: string | null | undefined): string[] {
    if (!conversationId) {
      return [];
    }

    const now = Date.now();

    return this.typingEntriesSignal()
      .filter((entry) => entry.conversationId === conversationId && entry.expiresAt > now)
      .map((entry) => entry.displayName);
  }

  sendTyping(conversationId: string, isTyping = true): void {
    const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
    const currentUser = this.currentUser();
    const currentUserId = this.getCurrentUserId();
    const recipientIds = this.recipientIdsFor(conversation, currentUserId);

    if (!conversation || !currentUser || recipientIds.length === 0) {
      return;
    }

    for (const recipientId of recipientIds) {
      this.delivery.sendViaWebRTC(recipientId, {
        type: 'direct-message-typing',
        directMessageTyping: {
          conversationId,
          sender: toDirectMessageParticipant(currentUser),
          isTyping,
          updatedAt: Date.now()
        }
      });
    }
  }

  private async handlePeerEvent(event: ChatEvent): Promise<void> {
    if (event.type === 'direct-message' && event.directMessage) {
      await this.handleIncomingMessage(event.directMessage);
      return;
    }

    if (event.type === 'direct-message-status' && event.directMessageStatus) {
      await this.handleIncomingStatus(event.directMessageStatus);
      return;
    }

    if (event.type === 'direct-message-mutation' && event.directMessageMutation) {
      await this.handleIncomingMutation(event.directMessageMutation);
      return;
    }

    if (event.type === 'direct-message-typing' && event.directMessageTyping) {
      this.handleIncomingTyping(event.directMessageTyping);
      return;
    }

    if (event.type === 'direct-message-sync-request' && event.directMessageSyncRequest) {
      await this.handleIncomingSyncRequest(event.directMessageSyncRequest);
      return;
    }

    if (event.type === 'direct-message-sync' && event.directMessageSync) {
      await this.handleIncomingSync(event.directMessageSync);
    }
  }

  private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> {
    const ownerId = this.getCurrentUserIdOrThrow();
    const currentUser = this.requireCurrentUser();

    if (!directMessageEventIncludesUser(payload, ownerId) || payload.sender.userId === ownerId || payload.message.senderId === ownerId) {
      return;
    }

    const currentParticipant = toDirectMessageParticipant(currentUser);
    const sender = payload.sender;
    const conversationId = payload.message.conversationId
      || getDirectConversationId(currentParticipant.userId, sender.userId);
    const participants = this.uniqueParticipants([
      currentParticipant,
      sender,
      ...(payload.participants ?? [])
    ]);
    const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
      ?? (payload.conversationKind === 'group' || participants.length > 2
        ? createGroupConversation(conversationId, participants, payload.message.timestamp, payload.conversationTitle)
        : createDirectConversation(currentParticipant, sender, payload.message.timestamp));
    const conversationWithParticipants = this.mergeConversationParticipants(existingConversation, participants);
    const incomingMessage: DirectMessage = {
      ...payload.message,
      conversationId,
      status: advanceDirectMessageStatus(payload.message.status, 'DELIVERED')
    };
    const shouldIncrementUnread = !this.isConversationVisible(conversationId);

    await this.persistConversation(ownerId, upsertDirectMessage(conversationWithParticipants, incomingMessage, shouldIncrementUnread));
    this.sendStatusUpdate(incomingMessage.senderId, {
      conversationId,
      messageId: incomingMessage.id,
      status: 'DELIVERED',
      updatedAt: Date.now()
    });

    if (!shouldIncrementUnread) {
      await this.markRead(conversationId);
    }
  }

  private async handleIncomingStatus(payload: DirectMessageStatusEventPayload): Promise<void> {
    await this.updateStatus(payload.messageId, payload.status);

    if (payload.status === 'DELIVERED' || payload.status === 'ACKNOWLEDGED') {
      await this.offlineQueue.markDelivered(this.getCurrentUserIdOrThrow(), payload.messageId);
    }
  }

  private isConversationVisible(conversationId: string): boolean {
    const currentUrl = this.router.url.split(/[?#]/, 1)[0];

    if (!currentUrl.startsWith('/dm/') && !currentUrl.startsWith('/pm/')) {
      if (currentUrl.startsWith('/call/')) {
        return this.selectedConversationIdSignal() === conversationId;
      }

      return false;
    }

    const prefix = currentUrl.startsWith('/pm/') ? '/pm/' : '/dm/';

    try {
      return decodeURIComponent(currentUrl.slice(prefix.length)) === conversationId;
    } catch {
      return currentUrl.slice(prefix.length) === conversationId;
    }
  }

  private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> {
    const ownerId = this.getCurrentUserIdOrThrow();
    const conversation = await this.findConversation(ownerId, payload.conversationId);

    if (!conversation || !directMessageConversationIncludesUser(conversation, ownerId)) {
      return;
    }

    await this.persistConversation(ownerId, this.applyMutation(conversation, payload));
  }

  private handleIncomingTyping(payload: DirectMessageTypingEventPayload): void {
    const currentUserId = this.getCurrentUserId();

    if (!currentUserId || payload.sender.userId === currentUserId) {
      return;
    }

    const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId);

    if (!conversation
      || !directMessageConversationIncludesUser(conversation, currentUserId)
      || !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
      return;
    }

    if (!payload.isTyping) {
      this.typingEntriesSignal.update((entries) => entries.filter((entry) =>
        !(entry.conversationId === payload.conversationId && entry.userId === payload.sender.userId)
      ));

      return;
    }

    const nextEntry: DirectMessageTypingEntry = {
      conversationId: payload.conversationId,
      userId: payload.sender.userId,
      displayName: payload.sender.displayName,
      expiresAt: Date.now() + DIRECT_MESSAGE_TYPING_TTL_MS
    };

    this.typingEntriesSignal.update((entries) => [
      ...entries.filter((entry) =>
        !(entry.conversationId === nextEntry.conversationId && entry.userId === nextEntry.userId)
      ),
      nextEntry
    ]);
  }

  private async handleIncomingSyncRequest(payload: DirectMessageSyncRequestEventPayload): Promise<void> {
    const ownerId = this.getCurrentUserIdOrThrow();
    const currentUser = this.requireCurrentUser();
    const conversation = await this.findConversation(ownerId, payload.conversationId);

    if (!conversation
      || payload.sender.userId === ownerId
      || !directMessageConversationIncludesUser(conversation, ownerId)
      || !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
      return;
    }

    this.delivery.sendViaWebRTC(payload.sender.userId, {
      type: 'direct-message-sync',
      directMessageSync: {
        conversationId: conversation.id,
        sender: toDirectMessageParticipant(currentUser),
        participants: Object.values(conversation.participantProfiles),
        conversationKind: this.conversationKind(conversation),
        conversationTitle: conversation.title,
        messages: conversation.messages.slice(-DIRECT_MESSAGE_SYNC_LIMIT),
        syncedAt: Date.now()
      }
    });
  }

  private async handleIncomingSync(payload: DirectMessageSyncEventPayload): Promise<void> {
    const ownerId = this.getCurrentUserIdOrThrow();
    const currentUser = this.requireCurrentUser();
    const currentParticipant = toDirectMessageParticipant(currentUser);

    if (payload.sender.userId === ownerId) {
      return;
    }

    if (!directMessageSyncIncludesUser(payload, ownerId) || !directMessageSyncIncludesUser(payload, payload.sender.userId)) {
      return;
    }

    const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === payload.conversationId)
      ?? await this.repository.getConversation(ownerId, payload.conversationId)
      ?? (payload.conversationKind === 'group' || payload.participants.length > 2
        ? createGroupConversation(payload.conversationId, [currentParticipant, ...payload.participants], payload.syncedAt, payload.conversationTitle)
        : createDirectConversation(currentParticipant, payload.sender, payload.syncedAt));
    const participantProfiles = {
      ...existingConversation.participantProfiles,
      ...Object.fromEntries(payload.participants.map((participant) => [participant.userId, participant])),
      [currentParticipant.userId]: currentParticipant
    };
    const syncBaseConversation: DirectMessageConversation = {
      ...existingConversation,
      kind: payload.conversationKind ?? existingConversation.kind,
      title: payload.conversationTitle ?? existingConversation.title,
      participants: Object.keys(participantProfiles).sort(),
      participantProfiles
    };
    const mergedConversation = payload.messages.reduce<DirectMessageConversation>(
      (conversation, message) => upsertDirectMessage(conversation, message, false),
      syncBaseConversation
    );

    await this.persistConversation(ownerId, mergedConversation);

    if (this.selectedConversationIdSignal() === payload.conversationId) {
      await this.markRead(payload.conversationId);
    }
  }

  private async applyAndSendMutation(
    conversationId: string,
    payload: DirectMessageMutationEventPayload
  ): Promise<void> {
    const ownerId = this.getCurrentUserIdOrThrow();
    const conversation = await this.requireConversation(ownerId, conversationId);
    const updatedConversation = this.applyMutation(conversation, payload);
    const recipientIds = this.recipientIdsFor(conversation, ownerId);

    await this.persistConversation(ownerId, updatedConversation);

    if (payload.type === 'edit' && payload.content) {
      this.customEmoji.pushEmojisInContent(payload.content);
    } else if (payload.type === 'reaction-add' && payload.reaction?.emoji) {
      this.customEmoji.pushEmojisInContent(payload.reaction.emoji);
    }

    for (const recipientId of recipientIds) {
      this.delivery.sendViaWebRTC(recipientId, {
        type: 'direct-message-mutation',
        directMessageMutation: payload
      });
    }
  }

  private applyMutation(
    conversation: DirectMessageConversation,
    payload: DirectMessageMutationEventPayload
  ): DirectMessageConversation {
    const messages = conversation.messages.map((message) => {
      if (message.id !== payload.messageId) {
        return message;
      }

      if (payload.type === 'edit' && payload.content) {
        return {
          ...message,
          content: payload.content,
          editedAt: payload.editedAt ?? payload.updatedAt,
          isDeleted: false
        };
      }

      if (payload.type === 'delete') {
        return {
          ...message,
          content: '',
          isDeleted: true,
          editedAt: payload.updatedAt
        };
      }

      if (payload.type === 'reaction-add' && payload.reaction) {
        const reactions = (message.reactions ?? []).filter((reaction) =>
          !(reaction.emoji === payload.reaction?.emoji && reaction.userId === payload.reaction.userId)
        );

        return {
          ...message,
          reactions: [...reactions, payload.reaction]
        };
      }

      if (payload.type === 'reaction-remove' && payload.oderId && payload.emoji) {
        return {
          ...message,
          reactions: (message.reactions ?? []).filter((reaction) =>
            !(reaction.emoji === payload.emoji && (reaction.userId === payload.oderId || reaction.oderId === payload.oderId))
          )
        };
      }

      return message;
    });

    return { ...conversation, messages };
  }

  private async attemptDelivery(ownerId: string, message: DirectMessage, conversation: DirectMessageConversation): Promise<void> {
    const currentUser = this.requireCurrentUser();
    const recipientIds = this.recipientIdsFor(conversation, ownerId);

    let sentCount = 0;

    this.customEmoji.pushEmojisInContent(message.content);

    for (const recipientId of recipientIds) {
      if (this.delivery.sendViaWebRTC(recipientId, {
        type: 'direct-message',
        directMessage: {
          message,
          sender: toDirectMessageParticipant(currentUser),
          participants: Object.values(conversation.participantProfiles),
          conversationKind: this.conversationKind(conversation),
          conversationTitle: conversation.title
        }
      })) {
        sentCount += 1;
      }
    }

    if (sentCount < recipientIds.length) {
      await this.offlineQueue.enqueue(ownerId, message.id);
    }

    if (sentCount > 0) {
      await this.updateStatus(message.id, 'SENT');
    }

    if (sentCount === recipientIds.length) {
      await this.offlineQueue.markDelivered(ownerId, message.id);
    }
  }

  private sendStatusUpdate(recipientId: string, payload: DirectMessageStatusEventPayload): void {
    this.delivery.handleAck(recipientId, {
      type: 'direct-message-status',
      directMessageStatus: payload
    });
  }

  private requestOpenConversationSync(): void {
    const conversationId = this.selectedConversationIdSignal();

    if (conversationId) {
      this.requestConversationSync(conversationId);
    }
  }

  private requestConversationSync(conversationId: string): void {
    const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
    const currentUser = this.currentUser();
    const currentUserId = this.getCurrentUserId();
    const recipientIds = this.recipientIdsFor(conversation, currentUserId);

    if (!conversation || !currentUser || recipientIds.length === 0) {
      return;
    }

    const now = Date.now();

    for (const recipientId of recipientIds) {
      const syncKey = `${conversationId}:${recipientId}`;

      if (now - (this.lastSyncRequestAt.get(syncKey) ?? 0) < DIRECT_MESSAGE_SYNC_REQUEST_COOLDOWN_MS) {
        continue;
      }

      this.lastSyncRequestAt.set(syncKey, now);

      this.delivery.sendViaWebRTC(recipientId, {
        type: 'direct-message-sync-request',
        directMessageSyncRequest: {
          conversationId,
          sender: toDirectMessageParticipant(currentUser),
          requestedAt: Date.now()
        }
      });
    }
  }

  private purgeExpiredTypingEntries(): void {
    const now = Date.now();

    this.typingEntriesSignal.update((entries) => entries.filter((entry) => entry.expiresAt > now));
  }

  private async loadForOwner(ownerId: string | null): Promise<void> {
    if (!ownerId) {
      this.loadedOwnerId = null;
      this.conversationsSignal.set([]);
      return;
    }

    if (this.loadedOwnerId === ownerId) {
      return;
    }

    this.loadedOwnerId = ownerId;
    const conversations = await this.repository.loadConversations(ownerId);

    conversations.forEach((conversation) => this.rememberConversationAttachmentStorage(conversation));
    this.conversationsSignal.set(conversations);
  }

  private async persistConversation(ownerId: string, conversation: DirectMessageConversation): Promise<void> {
    this.rememberConversationAttachmentStorage(conversation);
    await this.repository.saveConversation(ownerId, conversation);
    this.conversationsSignal.update((conversations) => {
      const nextConversations = conversations.filter((entry) => entry.id !== conversation.id);

      nextConversations.push(conversation);
      return nextConversations;
    });
  }

  private rememberConversationAttachmentStorage(conversation: DirectMessageConversation): void {
    const storageContainer = `${DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX}${conversation.id}`;

    for (const message of conversation.messages) {
      this.attachments.rememberMessageRoom(message.id, storageContainer);
    }
  }

  private mergeConversationParticipants(
    conversation: DirectMessageConversation,
    participants: DirectMessageParticipant[]
  ): DirectMessageConversation {
    const participantProfiles = {
      ...conversation.participantProfiles,
      ...Object.fromEntries(participants.map((participant) => [participant.userId, participant]))
    };

    return {
      ...conversation,
      participants: Object.keys(participantProfiles).sort(),
      participantProfiles
    };
  }

  private createConversationForSystemEvent(
    conversationId: string,
    currentParticipant: DirectMessageParticipant,
    caller: DirectMessageParticipant,
    participants: DirectMessageParticipant[],
    timestamp: number
  ): DirectMessageConversation {
    if (participants.length > 2) {
      return createGroupConversation(conversationId, participants, timestamp);
    }

    const peer = participants.find((participant) => participant.userId !== currentParticipant.userId) ?? caller;

    return {
      ...createDirectConversation(currentParticipant, peer, timestamp),
      id: conversationId
    };
  }

  private recipientIdsFor(conversation: DirectMessageConversation | null | undefined, currentUserId: string | null | undefined): string[] {
    if (!conversation || !currentUserId) {
      return [];
    }

    return conversation.participants.filter((participantId) => participantId !== currentUserId);
  }

  private conversationKind(conversation: DirectMessageConversation): 'direct' | 'group' {
    return isGroupDirectConversation(conversation) ? 'group' : 'direct';
  }

  private uniqueParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
    const seen = new Set<string>();

    return participants.filter((participant) => {
      if (!participant.userId || seen.has(participant.userId)) {
        return false;
      }

      seen.add(participant.userId);
      return true;
    });
  }

  private async requireConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation> {
    const conversation = await this.findConversation(ownerId, conversationId);

    if (!conversation) {
      throw new Error('Direct message conversation not found.');
    }

    return conversation;
  }

  private async findConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation | null> {
    await this.loadForOwner(ownerId);

    return this.conversationsSignal().find((entry) => entry.id === conversationId)
      ?? await this.repository.getConversation(ownerId, conversationId);
  }

  private requireCurrentUser(): User {
    const currentUser = this.currentUser();

    if (!currentUser) {
      throw new Error('Cannot use direct messages without a current user.');
    }

    return currentUser;
  }

  private getCurrentUserId(): string | null {
    const user = this.currentUser();

    return user?.oderId || user?.id || null;
  }

  private getCurrentUserIdOrThrow(): string {
    const ownerId = this.getCurrentUserId();

    if (!ownerId) {
      throw new Error('Cannot use direct messages without a current user.');
    }

    return ownerId;
  }
}
