// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

import { assert } from 'chai';
import * as moment from 'moment';
import { v4 as uuid } from 'uuid';
import { SendStatus } from '../../../messages/MessageSendState.std.ts';
import type {
  MessageAttributesType,
  ShallowChallengeError,
} from '../../../model-types.d.ts';
import type { ConversationType } from '../../../state/ducks/conversations.preload.ts';
import {
  generateGroupId,
  getDefaultConversation,
  getDefaultGroup,
} from '../../../test-helpers/getDefaultConversation.std.ts';

import {
  canReact,
  canReply,
  cleanBodyForDirectionCheck,
  getMessagePropStatus,
  isEndSession,
  isGroupUpdate,
  isIncoming,
  isOutgoing,
} from '../../../state/selectors/message.preload.ts';
import type {
  DeleteForEveryoneConversation,
  DeleteForEveryoneMessage,
} from '../../../util/canDeleteForEveryone.preload.ts';
import { canSendDeleteForEveryone } from '../../../util/canDeleteForEveryone.preload.ts';
import type { AciString } from '../../../types/ServiceId.std.ts';
import { SIGNAL_ACI } from '../../../types/SignalConversation.std.ts';
import { OUR_ACI, setupBasics } from '../../backup/helpers.preload.ts';

describe('state/selectors/messages', () => {
  let ourConversationId: string;

  beforeEach(() => {
    ourConversationId = uuid();
  });

  describe('cleanBodyForDirectionCheck', () => {
    it('drops emoji', () => {
      const body = "😮😮😮😮 that's wild!";
      const expected = " that's wild!";
      const actual = cleanBodyForDirectionCheck(body);
      assert.strictEqual(actual, expected);
    });

    it('drops mentions', () => {
      const body = "heyo, how's it going \uFFFC? And \uFFFC too!";
      const expected = "heyo, how's it going ? And  too!";
      const actual = cleanBodyForDirectionCheck(body);
      assert.strictEqual(actual, expected);
    });

    it('drops links', () => {
      const body =
        'You should download it from https://signal.org/download. Then read something on https://signal.org/blog. Then donate at https://signal.org/donate.';
      const expected =
        'You should download it from . Then read something on . Then donate at .';
      const actual = cleanBodyForDirectionCheck(body);
      assert.strictEqual(actual, expected);
    });

    it('drops all of them at the same time', () => {
      const body =
        'https://signal.org/download 😮 \uFFFC Did you really join Signal?';
      const expected = '   Did you really join Signal?';
      const actual = cleanBodyForDirectionCheck(body);
      assert.strictEqual(actual, expected);
    });
  });

  describe('canDeleteForEveryone', () => {
    const GROUP_ID = generateGroupId();
    const otherAci = uuid() as AciString;

    function getDefaultMessage(): DeleteForEveryoneMessage {
      return {
        type: 'outgoing',
        sourceServiceId: OUR_ACI,
        sent_at: Date.now() - 1000,
        serverTimestamp: undefined,
        deletedForEveryone: undefined,
        sms: undefined,
      };
    }

    function getDefaultConvo(): DeleteForEveryoneConversation {
      return {
        id: uuid(),
        e164: undefined,
        serviceId: undefined,
        groupId: undefined,
        groupVersion: undefined,
      };
    }

    beforeEach(async () => {
      await setupBasics();
    });

    it('rejects Signal conversation', () => {
      const result = canSendDeleteForEveryone({
        targetMessage: getDefaultMessage(),
        targetConversation: { ...getDefaultConvo(), serviceId: SIGNAL_ACI },
        ourAci: OUR_ACI,
        isDeleterGroupAdmin: false,
      });
      assert.deepStrictEqual(result, {
        ok: false,
        reason: 'signal conversation',
      });
    });

    it('rejects Note to Self', () => {
      const result = canSendDeleteForEveryone({
        targetMessage: getDefaultMessage(),
        targetConversation: { ...getDefaultConvo(), serviceId: OUR_ACI },
        ourAci: OUR_ACI,
        isDeleterGroupAdmin: false,
      });
      assert.deepStrictEqual(result, {
        ok: false,
        reason: 'note to self conversation',
      });
    });

    it('rejects already deleted messages', () => {
      const result = canSendDeleteForEveryone({
        targetMessage: { ...getDefaultMessage(), deletedForEveryone: true },
        targetConversation: getDefaultConvo(),
        ourAci: OUR_ACI,
        isDeleterGroupAdmin: false,
      });
      assert.deepStrictEqual(result, {
        ok: false,
        reason: 'already deleted',
      });
    });

    it('rejects SMS messages', () => {
      const result = canSendDeleteForEveryone({
        targetMessage: { ...getDefaultMessage(), sms: true },
        targetConversation: getDefaultConvo(),
        ourAci: OUR_ACI,
        isDeleterGroupAdmin: false,
      });
      assert.deepStrictEqual(result, {
        ok: false,
        reason: 'sms message',
      });
    });

    it('rejects messages that are too old', () => {
      const result = canSendDeleteForEveryone({
        targetMessage: {
          ...getDefaultMessage(),
          sent_at: Date.now() - moment.duration(25, 'hours').asMilliseconds(),
        },
        targetConversation: getDefaultConvo(),
        ourAci: OUR_ACI,
        isDeleterGroupAdmin: false,
      });
      assert.deepStrictEqual(result, {
        ok: false,
        reason: 'no permission',
      });
    });

    it('rejects when deleter is not the author', () => {
      const result = canSendDeleteForEveryone({
        targetMessage: {
          ...getDefaultMessage(),
          type: 'incoming',
          sourceServiceId: otherAci,
        },
        targetConversation: getDefaultConvo(),
        ourAci: OUR_ACI,
        isDeleterGroupAdmin: false,
      });
      assert.deepStrictEqual(result, {
        ok: false,
        reason: 'no permission',
      });
    });

    it('accepts with isAdminDelete=false for own recent message', () => {
      const result = canSendDeleteForEveryone({
        targetMessage: getDefaultMessage(),
        targetConversation: getDefaultConvo(),
        ourAci: OUR_ACI,
        isDeleterGroupAdmin: false,
      });
      assert.deepStrictEqual(result, {
        ok: true,
        needsAdminDelete: false,
      });
    });

    it('accepts with isAdminDelete=true for admin deleting others message', () => {
      const result = canSendDeleteForEveryone({
        targetMessage: {
          ...getDefaultMessage(),
          type: 'incoming',
          sourceServiceId: otherAci,
        },
        targetConversation: {
          ...getDefaultConvo(),
          groupId: GROUP_ID,
          groupVersion: 2,
        },
        ourAci: OUR_ACI,
        isDeleterGroupAdmin: true,
      });
      assert.deepStrictEqual(result, {
        ok: true,
        needsAdminDelete: true,
      });
    });

    it('prefers normal delete over admin delete for own messages', () => {
      const result = canSendDeleteForEveryone({
        targetMessage: getDefaultMessage(),
        targetConversation: {
          ...getDefaultConvo(),
          groupId: GROUP_ID,
          groupVersion: 2,
        },
        ourAci: OUR_ACI,
        isDeleterGroupAdmin: true,
      });
      assert.deepStrictEqual(result, {
        ok: true,
        needsAdminDelete: false,
      });
    });
  });

  describe('canReact', () => {
    const defaultConversation = getDefaultConversation();

    it('returns false for disabled v1 groups', () => {
      const message = {
        conversationId: 'fake-conversation-id',
        type: 'incoming' as const,
      };
      const getConversationById = () => ({
        ...getDefaultGroup(),
        isGroupV1AndDisabled: true,
      });

      assert.isFalse(canReact(message, ourConversationId, getConversationById));
    });

    // NOTE: This is missing a test for mandatory profile sharing.

    it('returns false if the message was deleted for everyone', () => {
      const message = {
        conversationId: 'fake-conversation-id',
        type: 'incoming' as const,
        deletedForEveryone: true,
      };
      const getConversationById = () => defaultConversation;

      assert.isFalse(canReact(message, ourConversationId, getConversationById));
    });

    it('returns false for outgoing messages that have not been sent', () => {
      const message = {
        conversationId: 'fake-conversation-id',
        type: 'outgoing' as const,
        sendStateByConversationId: {
          [ourConversationId]: {
            status: SendStatus.Sent,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Pending,
            updatedAt: Date.now(),
          },
        },
      };
      const getConversationById = () => defaultConversation;

      assert.isFalse(canReact(message, ourConversationId, getConversationById));
    });

    it('returns true for outgoing messages that are only sent to yourself', () => {
      const message = {
        conversationId: 'fake-conversation-id',
        type: 'outgoing' as const,
        sendStateByConversationId: {
          [ourConversationId]: {
            status: SendStatus.Pending,
            updatedAt: Date.now(),
          },
        },
      };
      const getConversationById = () => defaultConversation;

      assert.isTrue(canReact(message, ourConversationId, getConversationById));
    });

    it('returns true for outgoing messages that have been sent to at least one person', () => {
      const message = {
        conversationId: 'fake-conversation-id',
        type: 'outgoing' as const,
        sendStateByConversationId: {
          [ourConversationId]: {
            status: SendStatus.Sent,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Pending,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Sent,
            updatedAt: Date.now(),
          },
        },
      };
      const getConversationById = () => ({
        ...getDefaultGroup(),
      });

      assert.isTrue(canReact(message, ourConversationId, getConversationById));
    });

    it('returns true for incoming messages', () => {
      const message = {
        conversationId: 'fake-conversation-id',
        type: 'incoming' as const,
      };
      const getConversationById = () => defaultConversation;

      assert.isTrue(canReact(message, ourConversationId, getConversationById));
    });
  });

  describe('canReply', () => {
    const defaultConversation: ConversationType = {
      id: uuid(),
      type: 'direct',
      title: 'Test conversation',
      isMe: false,
      acceptedMessageRequest: true,
      badges: [],
    };

    it('returns false for disabled v1 groups', () => {
      const message = {
        conversationId: 'fake-conversation-id',
        type: 'incoming' as const,
      };
      const getConversationById = () => ({
        ...getDefaultGroup(),
        isGroupV1AndDisabled: true,
      });

      assert.isFalse(canReply(message, ourConversationId, getConversationById));
    });

    // NOTE: This is missing a test for mandatory profile sharing.

    it('returns false if the message was deleted for everyone', () => {
      const message = {
        conversationId: 'fake-conversation-id',
        type: 'incoming' as const,
        deletedForEveryone: true,
      };
      const getConversationById = () => defaultConversation;

      assert.isFalse(canReply(message, ourConversationId, getConversationById));
    });

    it('returns false for outgoing messages that have not been sent', () => {
      const message = {
        conversationId: 'fake-conversation-id',
        type: 'outgoing' as const,
        sendStateByConversationId: {
          [ourConversationId]: {
            status: SendStatus.Sent,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Pending,
            updatedAt: Date.now(),
          },
        },
      };
      const getConversationById = () => defaultConversation;

      assert.isFalse(canReply(message, ourConversationId, getConversationById));
    });

    it('returns true for outgoing messages that are only sent to yourself', () => {
      const message = {
        conversationId: 'fake-conversation-id',
        type: 'outgoing' as const,
        sendStateByConversationId: {
          [ourConversationId]: {
            status: SendStatus.Pending,
            updatedAt: Date.now(),
          },
        },
      };
      const getConversationById = () => defaultConversation;

      assert.isTrue(canReply(message, ourConversationId, getConversationById));
    });

    it('returns true for outgoing messages that have been sent to at least one person', () => {
      const message = {
        conversationId: 'fake-conversation-id',
        type: 'outgoing' as const,
        sendStateByConversationId: {
          [ourConversationId]: {
            status: SendStatus.Sent,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Pending,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Sent,
            updatedAt: Date.now(),
          },
        },
      };
      const getConversationById = () => getDefaultGroup();

      assert.isTrue(canReply(message, ourConversationId, getConversationById));
    });

    it('returns true for incoming messages', () => {
      const message = {
        conversationId: 'fake-conversation-id',
        type: 'incoming' as const,
      };
      const getConversationById = () => defaultConversation;

      assert.isTrue(canReply(message, ourConversationId, getConversationById));
    });
  });

  describe('getMessagePropStatus', () => {
    const createMessage = (overrides: Partial<MessageAttributesType>) => ({
      type: 'outgoing' as const,
      ...overrides,
    });

    it('returns undefined for incoming messages with no errors', () => {
      const message = createMessage({ type: 'incoming' });

      assert.isUndefined(getMessagePropStatus(message, ourConversationId));
    });

    it('returns "error" for incoming messages with errors', () => {
      const message = createMessage({
        type: 'incoming',
        errors: [new Error('something went wrong')],
      });

      assert.strictEqual(
        getMessagePropStatus(message, ourConversationId),
        'error'
      );
    });

    it('returns "paused" for messages with challenges', () => {
      const challengeError: ShallowChallengeError = Object.assign(
        new Error('a challenge'),
        {
          name: 'SendMessageChallengeError',
          retryAfter: 123,
          data: {},
        }
      );
      const message = createMessage({ errors: [challengeError] });

      assert.strictEqual(
        getMessagePropStatus(message, ourConversationId),
        'paused'
      );
    });

    it('returns "partial-sent" if the message has errors but was sent to at least one person', () => {
      const message = createMessage({
        errors: [new Error('whoopsie')],
        sendStateByConversationId: {
          [ourConversationId]: {
            status: SendStatus.Sent,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Pending,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Delivered,
            updatedAt: Date.now(),
          },
        },
      });

      assert.strictEqual(
        getMessagePropStatus(message, ourConversationId),
        'partial-sent'
      );
    });

    it('returns "error" if the message has errors and has not been sent', () => {
      const message = createMessage({
        errors: [new Error('whoopsie')],
        sendStateByConversationId: {
          [ourConversationId]: {
            status: SendStatus.Pending,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Pending,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Pending,
            updatedAt: Date.now(),
          },
        },
      });

      assert.strictEqual(
        getMessagePropStatus(message, ourConversationId),
        'error'
      );
    });

    it('returns "viewed" if the message is just for you and has been sent', () => {
      const message = createMessage({
        sendStateByConversationId: {
          [ourConversationId]: {
            status: SendStatus.Sent,
            updatedAt: Date.now(),
          },
        },
      });

      assert.strictEqual(
        getMessagePropStatus(message, ourConversationId),
        'viewed'
      );
    });

    it('returns "viewed" if the message was viewed by at least one person', () => {
      const message = createMessage({
        sendStateByConversationId: {
          [ourConversationId]: {
            status: SendStatus.Sent,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Viewed,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Read,
            updatedAt: Date.now(),
          },
        },
      });
      assert.strictEqual(
        getMessagePropStatus(message, ourConversationId),
        'viewed'
      );
    });

    it('returns "read" if the message was read by at least one person', () => {
      const message = createMessage({
        sendStateByConversationId: {
          [ourConversationId]: {
            status: SendStatus.Sent,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Read,
            updatedAt: Date.now(),
          },
        },
      });
      assert.strictEqual(
        getMessagePropStatus(message, ourConversationId),
        'read'
      );
    });

    it('returns "delivered" if the message was delivered to at least one person, but no "higher"', () => {
      const message = createMessage({
        sendStateByConversationId: {
          [ourConversationId]: {
            status: SendStatus.Sent,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Pending,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Sent,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Delivered,
            updatedAt: Date.now(),
          },
        },
      });

      assert.strictEqual(
        getMessagePropStatus(message, ourConversationId),
        'delivered'
      );
    });

    it('returns "sent" if the message was sent to at least one person, but no "higher"', () => {
      const message = createMessage({
        sendStateByConversationId: {
          [ourConversationId]: {
            status: SendStatus.Sent,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Pending,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Sent,
            updatedAt: Date.now(),
          },
        },
      });

      assert.strictEqual(
        getMessagePropStatus(message, ourConversationId),
        'sent'
      );
    });

    it('returns "sending" if the message has not been sent yet, even if it has been synced to yourself', () => {
      const message = createMessage({
        sendStateByConversationId: {
          [ourConversationId]: {
            status: SendStatus.Sent,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Pending,
            updatedAt: Date.now(),
          },
          [uuid()]: {
            status: SendStatus.Pending,
            updatedAt: Date.now(),
          },
        },
      });

      assert.strictEqual(
        getMessagePropStatus(message, ourConversationId),
        'sending'
      );
    });
  });

  describe('isEndSession', () => {
    it('checks if it is end of the session', () => {
      assert.isFalse(isEndSession({}));
      assert.isFalse(isEndSession({ flags: undefined }));
      assert.isFalse(isEndSession({ flags: 0 }));
      assert.isFalse(isEndSession({ flags: 2 }));
      assert.isFalse(isEndSession({ flags: 4 }));

      assert.isTrue(isEndSession({ flags: 1 }));
    });
  });

  describe('isGroupUpdate', () => {
    it('checks if is group update', () => {
      assert.isFalse(isGroupUpdate({}));
      assert.isFalse(isGroupUpdate({ group_update: undefined }));

      assert.isTrue(isGroupUpdate({ group_update: { left: 'You' } }));
    });
  });

  describe('isIncoming', () => {
    it('checks if is incoming message', () => {
      assert.isFalse(isIncoming({ type: 'outgoing' }));
      assert.isFalse(isIncoming({ type: 'call-history' }));

      assert.isTrue(isIncoming({ type: 'incoming' }));
    });
  });

  describe('isOutgoing', () => {
    it('checks if is outgoing message', () => {
      assert.isFalse(isOutgoing({ type: 'incoming' }));
      assert.isFalse(isOutgoing({ type: 'call-history' }));

      assert.isTrue(isOutgoing({ type: 'outgoing' }));
    });
  });
});
