import { Types } from 'mongoose';
import { ReactionType } from '../../common/enums/reaction-type.enum';
import { SortOrder } from '../../common/enums/sort-order.enum';
import { ChatService } from './chat.service';

describe('ChatService realtime message broadcasting', () => {
  const senderId = new Types.ObjectId().toString();
  const recipientId = new Types.ObjectId().toString();
  const conversationId = new Types.ObjectId().toString();

  let chatRepository: Record<string, jest.Mock>;
  let usersRepository: Record<string, jest.Mock>;
  let notificationsService: Record<string, jest.Mock>;
  let storageService: Record<string, jest.Mock>;
  let chatRealtimeService: Record<string, jest.Mock>;
  let service: ChatService;

  const conversation = {
    id: conversationId,
    participantIds: [new Types.ObjectId(senderId), new Types.ObjectId(recipientId)],
  };

  beforeEach(() => {
    chatRepository = {
      findConversationById: jest.fn().mockResolvedValue(conversation),
      findAnyBlockBetween: jest.fn().mockResolvedValue(null),
      createMessage: jest.fn(),
      updateConversationAfterNewMessage: jest.fn().mockResolvedValue(conversation),
      findMessageById: jest.fn(),
      markMessageSeen: jest.fn().mockResolvedValue(undefined),
      clearConversationUnreadForUser: jest.fn().mockResolvedValue(undefined),
      markMessageDelivered: jest.fn().mockResolvedValue(undefined),
      hideConversationForUser: jest.fn().mockResolvedValue(conversation),
      findDirectConversation: jest.fn().mockResolvedValue(null),
      createConversation: jest.fn(),
      findConversationsForUser: jest.fn().mockResolvedValue([]),
      countConversationsForUser: jest.fn().mockResolvedValue(0),
      findMessages: jest.fn().mockResolvedValue([]),
      countMessages: jest.fn().mockResolvedValue(0),
      unsendMessage: jest.fn(),
      setMessageReaction: jest.fn(),
      deleteMessageForUser: jest.fn().mockResolvedValue(undefined),
      createBlock: jest.fn().mockResolvedValue(undefined),
      removeBlock: jest.fn().mockResolvedValue(undefined),
      findBlock: jest.fn().mockResolvedValue(null),
      findBlocksByBlocker: jest.fn().mockResolvedValue([]),
    };
    usersRepository = {
      findById: jest.fn().mockResolvedValue({ isDisabled: false }),
    };
    notificationsService = {
      createMessageNotification: jest.fn().mockResolvedValue(null),
    };
    storageService = {
      saveFile: jest.fn().mockResolvedValue('/uploads/chat/media/message.jpg'),
      deleteFile: jest.fn().mockResolvedValue(undefined),
    };
    chatRealtimeService = {
      emitNewMessage: jest.fn(),
    };

    service = new ChatService(
      chatRepository as any,
      usersRepository as any,
      notificationsService as any,
      storageService as any,
      chatRealtimeService as any,
    );
  });

  it('emits new_message after a text message is created through the service', async () => {
    const message = {
      id: new Types.ObjectId().toString(),
      conversationId: new Types.ObjectId(conversationId),
      senderId: new Types.ObjectId(senderId),
      content: 'hello',
      messageType: 'text',
    };
    chatRepository.createMessage.mockResolvedValue(message);

    const result = await service.sendMessage(senderId, {
      conversationId,
      content: 'hello',
      messageType: 'text',
    });

    expect(result).toBe(message);
    expect(chatRealtimeService.emitNewMessage).toHaveBeenCalledTimes(1);
    expect(chatRealtimeService.emitNewMessage).toHaveBeenCalledWith(conversationId, message);
    expect(notificationsService.createMessageNotification).toHaveBeenCalledWith(
      senderId,
      recipientId,
      conversationId,
      'hello',
      message.id,
    );
  });

  it('emits new_message after a media upload message is created through the service', async () => {
    const message = {
      id: new Types.ObjectId().toString(),
      conversationId: new Types.ObjectId(conversationId),
      senderId: new Types.ObjectId(senderId),
      content: '',
      mediaUrl: '/uploads/chat/media/message.jpg',
      messageType: 'image',
    };
    chatRepository.createMessage.mockResolvedValue(message);

    const result = await service.sendMessageWithUpload(
      senderId,
      {
        conversationId,
      },
      {
        mimetype: 'image/jpeg',
        size: 1024,
        buffer: Buffer.from('image'),
        originalname: 'message.jpg',
      },
    );

    expect(result).toBe(message);
    expect(chatRepository.createMessage).toHaveBeenCalledWith(
      expect.objectContaining({
        conversationId,
        senderId,
        messageType: 'image',
        mediaUrl: '/uploads/chat/media/message.jpg',
      }),
    );
    expect(chatRealtimeService.emitNewMessage).toHaveBeenCalledTimes(1);
    expect(chatRealtimeService.emitNewMessage).toHaveBeenCalledWith(conversationId, message);
  });

  it('marks a message delivered for a participant that is not the sender', async () => {
    const messageId = new Types.ObjectId().toString();
    chatRepository.findMessageById.mockResolvedValue({
      id: messageId,
      conversationId: new Types.ObjectId(conversationId),
      senderId: new Types.ObjectId(senderId),
    });

    const result = await service.markMessageDelivered(recipientId, conversationId, messageId);

    expect(chatRepository.markMessageDelivered).toHaveBeenCalledWith(
      messageId,
      recipientId,
      expect.any(Date),
    );
    expect(result).toMatchObject({
      conversationId,
      messageId,
      userId: recipientId,
    });
    expect(result.deliveredAt).toEqual(expect.any(String));
  });

  it('does not let the sender mark their own message delivered', async () => {
    const messageId = new Types.ObjectId().toString();
    chatRepository.findMessageById.mockResolvedValue({
      id: messageId,
      conversationId: new Types.ObjectId(conversationId),
      senderId: new Types.ObjectId(senderId),
    });

    await expect(service.markMessageDelivered(senderId, conversationId, messageId)).rejects.toThrow(
      'Sender cannot mark own message delivered',
    );
    expect(chatRepository.markMessageDelivered).not.toHaveBeenCalled();
  });

  it('marks a message seen and returns conversationId and seenAt for realtime payloads', async () => {
    const messageId = new Types.ObjectId().toString();
    chatRepository.findMessageById.mockResolvedValue({
      id: messageId,
      conversationId: new Types.ObjectId(conversationId),
      senderId: new Types.ObjectId(senderId),
    });

    const result = await service.markMessageSeen(recipientId, messageId, conversationId);

    expect(chatRepository.markMessageSeen).toHaveBeenCalledWith(messageId, recipientId);
    expect(chatRepository.clearConversationUnreadForUser).toHaveBeenCalledWith(conversationId, recipientId);
    expect(result).toMatchObject({
      success: true,
      conversationId,
      messageId,
      userId: recipientId,
    });
    expect(result.seenAt).toEqual(expect.any(String));
  });

  it('hides a conversation for the current user only', async () => {
    const result = await service.hideConversationForMe(senderId, conversationId);

    expect(chatRepository.findConversationById).toHaveBeenCalledWith(conversationId);
    expect(chatRepository.hideConversationForUser).toHaveBeenCalledWith(
      conversationId,
      senderId,
      expect.any(Date),
    );
    expect(chatRepository.createMessage).not.toHaveBeenCalled();
    expect(result).toEqual({
      success: true,
      conversationId,
      deletedForMe: true,
    });
  });

  it('does not hide a conversation for a non-participant', async () => {
    const outsiderId = new Types.ObjectId().toString();

    await expect(service.hideConversationForMe(outsiderId, conversationId)).rejects.toThrow(
      'You are not a member of this conversation',
    );
    expect(chatRepository.hideConversationForUser).not.toHaveBeenCalled();
  });

  describe('conversation lifecycle', () => {
    it('requires another participant and validates every participant id', async () => {
      await expect(
        service.createConversation(senderId, { participantIds: [senderId] }),
      ).rejects.toThrow('Conversation must include at least 2 participants');

      await expect(
        service.createConversation(senderId, { participantIds: ['not-an-object-id'] }),
      ).rejects.toThrow('Invalid participant id');
      expect(usersRepository.findById).not.toHaveBeenCalled();
    });

    it('rejects disabled participants and an invalid direct-conversation shape', async () => {
      usersRepository.findById.mockResolvedValueOnce({ isDisabled: false }).mockResolvedValueOnce({
        isDisabled: true,
      });

      await expect(
        service.createConversation(senderId, { participantIds: [recipientId] }),
      ).rejects.toThrow('One or more participants are invalid or disabled');

      const thirdId = new Types.ObjectId().toString();
      await expect(
        service.createConversation(senderId, {
          participantIds: [recipientId, thirdId],
          isGroup: false,
        }),
      ).rejects.toThrow('Direct conversation must contain exactly 2 participants');
    });

    it('returns an existing direct conversation and enforces chat blocks', async () => {
      const existing = { id: conversationId };
      chatRepository.findDirectConversation.mockResolvedValue(existing);

      await expect(
        service.createConversation(senderId, { participantIds: [recipientId] }),
      ).resolves.toBe(existing);
      expect(chatRepository.createConversation).not.toHaveBeenCalled();

      chatRepository.findAnyBlockBetween.mockResolvedValue({ id: 'block-1' });
      await expect(
        service.createConversation(senderId, { participantIds: [recipientId] }),
      ).rejects.toThrow('You cannot start chat with this user');
    });

    it('creates a deduplicated group conversation', async () => {
      const thirdId = new Types.ObjectId().toString();
      const created = { id: conversationId, isGroup: true };
      chatRepository.createConversation.mockResolvedValue(created);

      await expect(
        service.createConversation(senderId, {
          participantIds: [recipientId, recipientId, thirdId],
          title: 'Band',
        }),
      ).resolves.toBe(created);
      expect(chatRepository.createConversation).toHaveBeenCalledWith({
        participantIds: [senderId, recipientId, thirdId],
        isGroup: true,
        title: 'Band',
        createdBy: senderId,
      });
    });

    it('maps unread counters and cursor metadata in the conversation list', async () => {
      const conversations = [
        {
          lastMessageAt: undefined,
          toObject: () => ({ id: 'c1', unreadCountByUser: new Map([[senderId, '2']]) }),
        },
        {
          lastMessageAt: new Date('2025-01-01T00:00:00.000Z'),
          toObject: () => ({
            id: 'c2',
            unreadCountByUser: { [senderId]: 3, $__internal: 9, $isMongooseMap: true },
          }),
        },
        {
          lastMessageAt: undefined,
          toObject: () => ({ id: 'c3', unreadCountByUser: 'invalid' }),
        },
      ];
      chatRepository.findConversationsForUser.mockResolvedValue(conversations);
      chatRepository.countConversationsForUser.mockResolvedValue(5);

      const result = await service.getMyConversations(senderId, {
        page: 1,
        limit: 3,
        sortOrder: SortOrder.ASC,
      });

      expect(chatRepository.findConversationsForUser).toHaveBeenCalledWith(senderId, 0, 3, {
        lastMessageAt: 1,
        updatedAt: 1,
      });
      expect(result.items[0]).toMatchObject({ unreadCount: 2, lastMessageAt: null });
      expect(result.items[1].unreadCountByUser).toEqual({ [senderId]: 3 });
      expect(result.items[2].unreadCountByUser).toEqual({});
      expect(result.nextCursor).toEqual(expect.any(String));
    });

    it('lists messages using a cursor and clears unread state', async () => {
      const messages = [{ id: 'm1' }];
      chatRepository.findMessages.mockResolvedValue(messages);
      chatRepository.countMessages.mockResolvedValue(4);
      const cursor = Buffer.from('2', 'utf8').toString('base64url');

      const result = await service.getMessages(senderId, conversationId, {
        page: 9,
        limit: 1,
        cursor,
        sortOrder: SortOrder.ASC,
      });

      expect(chatRepository.findMessages).toHaveBeenCalledWith(
        conversationId,
        senderId,
        2,
        1,
        { createdAt: 1 },
      );
      expect(chatRepository.clearConversationUnreadForUser).toHaveBeenCalledWith(
        conversationId,
        senderId,
      );
      expect(result.items).toEqual(messages);
      expect(result.nextCursor).toEqual(expect.any(String));
    });

    it('requires a conversation id and reports a failed hide update', async () => {
      await expect(service.getMessages(senderId, '  ', {})).rejects.toThrow(
        'conversationId is required',
      );

      chatRepository.hideConversationForUser.mockResolvedValue(null);
      await expect(service.hideConversationForMe(senderId, conversationId)).rejects.toThrow(
        'Conversation not found',
      );
    });
  });

  describe('message validation and mutations', () => {
    it('requires text content and a URL for non-text messages', async () => {
      await expect(
        service.sendMessage(senderId, { conversationId, content: '   ', messageType: 'text' }),
      ).rejects.toThrow('Text message content is required');
      await expect(
        service.sendMessage(senderId, { conversationId, messageType: 'video' }),
      ).rejects.toThrow('mediaUrl is required for non-text messages');
    });

    it('prevents messaging any blocked participant', async () => {
      chatRepository.findAnyBlockBetween.mockResolvedValue({ id: 'block-1' });

      await expect(
        service.sendMessage(senderId, { conversationId, content: 'hello' }),
      ).rejects.toThrow('Cannot send message because one of participants is blocked');
      expect(chatRepository.createMessage).not.toHaveBeenCalled();
    });

    it('validates reply ids and accepts replies from the same conversation', async () => {
      await expect(
        service.sendMessage(senderId, {
          conversationId,
          content: 'reply',
          replyToMessageId: 'invalid',
        }),
      ).rejects.toThrow('Invalid replyToMessageId');

      const replyId = new Types.ObjectId().toString();
      chatRepository.findMessageById.mockResolvedValueOnce({
        id: replyId,
        conversationId: new Types.ObjectId(),
      });
      await expect(
        service.sendMessage(senderId, { conversationId, content: 'reply', replyToMessageId: replyId }),
      ).rejects.toThrow('Reply message must belong to the same conversation');

      const message = {
        id: new Types.ObjectId().toString(),
        conversationId: new Types.ObjectId(conversationId),
      };
      chatRepository.findMessageById.mockResolvedValueOnce({
        id: replyId,
        conversationId: new Types.ObjectId(conversationId),
      });
      chatRepository.createMessage.mockResolvedValue(message);
      notificationsService.createMessageNotification.mockRejectedValue(new Error('push unavailable'));

      await expect(
        service.sendMessage(senderId, { conversationId, content: 'reply', replyToMessageId: replyId }),
      ).resolves.toBe(message);
      expect(chatRepository.createMessage).toHaveBeenCalledWith(
        expect.objectContaining({ replyToMessageId: replyId }),
      );
    });

    it('requires an upload and cleans stored media when message creation fails', async () => {
      await expect(service.sendMessageWithUpload(senderId, { conversationId })).rejects.toThrow(
        'mediaFile is required',
      );

      chatRepository.createMessage.mockRejectedValue(new Error('database failed'));
      await expect(
        service.sendMessageWithUpload(
          senderId,
          { conversationId },
          {
            mimetype: 'image/png',
            size: 10,
            buffer: Buffer.from('png'),
            originalname: 'photo.png',
          },
        ),
      ).rejects.toThrow('database failed');
      expect(storageService.deleteFile).toHaveBeenCalledWith('/uploads/chat/media/message.jpg');
    });

    it('detects uploaded audio and rejects unsupported media', async () => {
      const message = {
        id: new Types.ObjectId().toString(),
        conversationId: new Types.ObjectId(conversationId),
      };
      storageService.saveFile.mockResolvedValue('/uploads/chat/media/message.mp3');
      chatRepository.createMessage.mockResolvedValue(message);

      await service.sendMessageWithUpload(
        senderId,
        { conversationId },
        {
          mimetype: 'audio/mpeg',
          size: 10,
          buffer: Buffer.from('audio'),
          originalname: 'voice.mp3',
        },
      );
      expect(storageService.saveFile).toHaveBeenCalledWith(
        expect.objectContaining({ extension: '.mp3', contentType: 'audio/mpeg' }),
      );
      expect(chatRepository.createMessage).toHaveBeenCalledWith(
        expect.objectContaining({ messageType: 'audio' }),
      );

      await expect(
        service.sendMessageWithUpload(
          senderId,
          { conversationId },
          {
            mimetype: 'application/pdf',
            size: 10,
            buffer: Buffer.from('pdf'),
            originalname: 'file.pdf',
          },
        ),
      ).rejects.toThrow('mediaFile must be image, video, or audio');
    });

    it('validates seen-message identifiers and ownership', async () => {
      await expect(service.markMessageSeen(senderId, 'invalid')).rejects.toThrow(
        'Invalid message id',
      );
      const messageId = new Types.ObjectId().toString();
      await expect(service.markMessageSeen(senderId, messageId, 'invalid')).rejects.toThrow(
        'Invalid conversation id',
      );

      chatRepository.findMessageById.mockResolvedValueOnce(null);
      await expect(service.markMessageSeen(senderId, messageId)).rejects.toThrow(
        'Message not found',
      );

      chatRepository.findMessageById.mockResolvedValueOnce({
        id: messageId,
        conversationId: new Types.ObjectId(),
      });
      await expect(service.markMessageSeen(senderId, messageId, conversationId)).rejects.toThrow(
        'Message must belong to the conversation',
      );
    });

    it('validates delivered-message identifiers and conversation ownership', async () => {
      await expect(
        service.markMessageDelivered(recipientId, conversationId, 'invalid'),
      ).rejects.toThrow('Invalid message id');

      const messageId = new Types.ObjectId().toString();
      chatRepository.findMessageById.mockResolvedValue(null);
      await expect(
        service.markMessageDelivered(recipientId, conversationId, messageId),
      ).rejects.toThrow('Message must belong to the conversation');
    });

    it('unsends only an existing message owned by the current user', async () => {
      const messageId = new Types.ObjectId().toString();
      chatRepository.findMessageById.mockResolvedValueOnce(null);
      await expect(service.unsendMessage(senderId, messageId)).rejects.toThrow('Message not found');

      chatRepository.findMessageById.mockResolvedValueOnce({
        id: messageId,
        senderId: new Types.ObjectId(recipientId),
      });
      await expect(service.unsendMessage(senderId, messageId)).rejects.toThrow(
        'You can only unsend your own messages',
      );

      chatRepository.findMessageById.mockResolvedValue({
        id: messageId,
        senderId: new Types.ObjectId(senderId),
      });
      chatRepository.unsendMessage.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: messageId });
      await expect(service.unsendMessage(senderId, messageId)).rejects.toThrow('Message not found');
      await expect(service.unsendMessage(senderId, messageId)).resolves.toEqual({ id: messageId });
    });

    it('reacts to and locally deletes messages only after membership checks', async () => {
      const messageId = new Types.ObjectId().toString();
      chatRepository.findMessageById.mockResolvedValueOnce(null);
      await expect(service.reactToMessage(senderId, messageId, ReactionType.LOVE)).rejects.toThrow(
        'Message not found',
      );

      const message = {
        id: messageId,
        conversationId: new Types.ObjectId(conversationId),
      };
      chatRepository.findMessageById.mockResolvedValue(message);
      chatRepository.setMessageReaction.mockResolvedValueOnce(null).mockResolvedValueOnce({
        id: messageId,
        reactions: [{ type: ReactionType.LOVE }],
      });
      await expect(service.reactToMessage(senderId, messageId, ReactionType.LOVE)).rejects.toThrow(
        'Message not found',
      );
      await expect(service.reactToMessage(senderId, messageId, ReactionType.LOVE)).resolves.toMatchObject({
        id: messageId,
      });

      await expect(service.deleteMessageForMe(senderId, messageId)).resolves.toEqual({ success: true });
      expect(chatRepository.deleteMessageForUser).toHaveBeenCalledWith(messageId, senderId);

      chatRepository.findMessageById.mockResolvedValue(null);
      await expect(service.deleteMessageForMe(senderId, messageId)).rejects.toThrow(
        'Message not found',
      );
    });
  });

  describe('chat block and membership rules', () => {
    it('validates conversation ids, existence, and membership', async () => {
      await expect(service.assertConversationMember(senderId, 'invalid')).rejects.toThrow(
        'Invalid conversation id',
      );
      chatRepository.findConversationById.mockResolvedValueOnce(null);
      await expect(service.assertConversationMember(senderId, conversationId)).rejects.toThrow(
        'Conversation not found',
      );
      const outsiderId = new Types.ObjectId().toString();
      await expect(service.assertConversationMember(outsiderId, conversationId)).rejects.toThrow(
        'You are not a member of this conversation',
      );
    });

    it('blocks and unblocks a valid existing target', async () => {
      await expect(service.blockUser(senderId, recipientId)).resolves.toEqual({
        blocked: true,
        targetUserId: recipientId,
      });
      expect(chatRepository.createBlock).toHaveBeenCalledWith(senderId, recipientId);

      await expect(service.unblockUser(senderId, recipientId)).resolves.toEqual({
        blocked: false,
        targetUserId: recipientId,
      });
      expect(chatRepository.removeBlock).toHaveBeenCalledWith(senderId, recipientId);
    });

    it('rejects invalid, self, and missing block targets', async () => {
      await expect(service.blockUser(senderId, 'invalid')).rejects.toThrow('Invalid target user id');
      await expect(service.blockUser(senderId, senderId)).rejects.toThrow('You cannot block yourself');

      usersRepository.findById.mockResolvedValue(null);
      await expect(service.blockUser(senderId, recipientId)).rejects.toThrow('Target user not found');
      await expect(service.unblockUser(senderId, 'invalid')).rejects.toThrow(
        'Invalid target user id',
      );
    });

    it('returns both block directions and the current block list', async () => {
      chatRepository.findBlock.mockResolvedValueOnce({ id: 'mine' }).mockResolvedValueOnce(null);
      await expect(service.getBlockStatus(senderId, recipientId)).resolves.toEqual({
        targetUserId: recipientId,
        iBlocked: true,
        blockedMe: false,
      });
      await expect(service.getBlockStatus(senderId, 'invalid')).rejects.toThrow(
        'Invalid target user id',
      );

      const items = [{ id: 'block-1' }];
      chatRepository.findBlocksByBlocker.mockResolvedValue(items);
      await expect(service.getMyBlockedUsers(senderId)).resolves.toEqual({ items });
    });
  });
});
