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

import { assert } from 'chai';
import { sampleSize, times } from 'lodash';
import { v4 as uuid } from 'uuid';

import type {
  SendAction,
  SendState,
  SendStateByConversationId,
} from '../../messages/MessageSendState';
import {
  SendActionType,
  SendStatus,
  getHighestSuccessfulRecipientStatus,
  isDelivered,
  isFailed,
  isMessageJustForMe,
  isRead,
  isSent,
  isViewed,
  maxStatus,
  sendStateReducer,
  someRecipientSendStatus,
  someSendStatus,
} from '../../messages/MessageSendState';

describe('message send state utilities', () => {
  describe('maxStatus', () => {
    const expectedOrder = [
      SendStatus.Failed,
      SendStatus.Pending,
      SendStatus.Sent,
      SendStatus.Delivered,
      SendStatus.Read,
      SendStatus.Viewed,
    ];

    it('returns the input if arguments are equal', () => {
      expectedOrder.forEach(status => {
        assert.strictEqual(maxStatus(status, status), status);
      });
    });

    it('orders the statuses', () => {
      times(100, () => {
        const [a, b] = sampleSize(expectedOrder, 2);
        const isABigger = expectedOrder.indexOf(a) > expectedOrder.indexOf(b);
        const expected = isABigger ? a : b;

        const actual = maxStatus(a, b);
        assert.strictEqual(actual, expected);
      });
    });
  });

  describe('isViewed', () => {
    it('returns true for viewed statuses', () => {
      assert.isTrue(isViewed(SendStatus.Viewed));
    });

    it('returns false for non-viewed statuses', () => {
      assert.isFalse(isViewed(SendStatus.Read));
      assert.isFalse(isViewed(SendStatus.Delivered));
      assert.isFalse(isViewed(SendStatus.Sent));
      assert.isFalse(isViewed(SendStatus.Pending));
      assert.isFalse(isViewed(SendStatus.Failed));
    });
  });

  describe('isRead', () => {
    it('returns true for read and viewed statuses', () => {
      assert.isTrue(isRead(SendStatus.Read));
      assert.isTrue(isRead(SendStatus.Viewed));
    });

    it('returns false for non-read statuses', () => {
      assert.isFalse(isRead(SendStatus.Delivered));
      assert.isFalse(isRead(SendStatus.Sent));
      assert.isFalse(isRead(SendStatus.Pending));
      assert.isFalse(isRead(SendStatus.Failed));
    });
  });

  describe('isDelivered', () => {
    it('returns true for delivered, read, and viewed statuses', () => {
      assert.isTrue(isDelivered(SendStatus.Delivered));
      assert.isTrue(isDelivered(SendStatus.Read));
      assert.isTrue(isDelivered(SendStatus.Viewed));
    });

    it('returns false for non-delivered statuses', () => {
      assert.isFalse(isDelivered(SendStatus.Sent));
      assert.isFalse(isDelivered(SendStatus.Pending));
      assert.isFalse(isDelivered(SendStatus.Failed));
    });
  });

  describe('isSent', () => {
    it('returns true for all statuses sent and "above"', () => {
      assert.isTrue(isSent(SendStatus.Sent));
      assert.isTrue(isSent(SendStatus.Delivered));
      assert.isTrue(isSent(SendStatus.Read));
      assert.isTrue(isSent(SendStatus.Viewed));
    });

    it('returns false for non-sent statuses', () => {
      assert.isFalse(isSent(SendStatus.Pending));
      assert.isFalse(isSent(SendStatus.Failed));
    });
  });

  describe('isFailed', () => {
    it('returns true for failed statuses', () => {
      assert.isTrue(isFailed(SendStatus.Failed));
    });

    it('returns false for non-failed statuses', () => {
      assert.isFalse(isFailed(SendStatus.Viewed));
      assert.isFalse(isFailed(SendStatus.Read));
      assert.isFalse(isFailed(SendStatus.Delivered));
      assert.isFalse(isFailed(SendStatus.Sent));
      assert.isFalse(isFailed(SendStatus.Pending));
    });
  });

  describe('someRecipientSendStatus', () => {
    const ourConversationId = uuid();
    it('returns false if there are no send states', () => {
      const alwaysTrue = () => true;
      assert.isFalse(
        someRecipientSendStatus({}, ourConversationId, alwaysTrue)
      );
      assert.isFalse(someRecipientSendStatus({}, undefined, alwaysTrue));
    });

    it('returns false if no send states match, excluding our own', () => {
      const sendStateByConversationId: SendStateByConversationId = {
        abc: {
          status: SendStatus.Sent,
          updatedAt: Date.now(),
        },
        def: {
          status: SendStatus.Delivered,
          updatedAt: Date.now(),
        },
        [ourConversationId]: {
          status: SendStatus.Read,
          updatedAt: Date.now(),
        },
      };

      assert.isFalse(
        someRecipientSendStatus(
          sendStateByConversationId,
          ourConversationId,
          (status: SendStatus) => status === SendStatus.Read
        )
      );
    });

    it('returns true if at least one send state matches', () => {
      const sendStateByConversationId: SendStateByConversationId = {
        abc: {
          status: SendStatus.Sent,
          updatedAt: Date.now(),
        },
        def: {
          status: SendStatus.Read,
          updatedAt: Date.now(),
        },
        [ourConversationId]: {
          status: SendStatus.Read,
          updatedAt: Date.now(),
        },
      };

      assert.isTrue(
        someRecipientSendStatus(
          sendStateByConversationId,
          ourConversationId,
          (status: SendStatus) => status === SendStatus.Read
        )
      );
    });
  });

  describe('someSendStatus', () => {
    const ourConversationId = uuid();
    it('returns false if there are no send states', () => {
      const alwaysTrue = () => true;
      assert.isFalse(someSendStatus({}, alwaysTrue));
    });

    it('returns false if no send states match', () => {
      const sendStateByConversationId: SendStateByConversationId = {
        abc: {
          status: SendStatus.Sent,
          updatedAt: Date.now(),
        },
        def: {
          status: SendStatus.Read,
          updatedAt: Date.now(),
        },
        [ourConversationId]: {
          status: SendStatus.Delivered,
          updatedAt: Date.now(),
        },
      };

      assert.isFalse(
        someSendStatus(
          sendStateByConversationId,
          (status: SendStatus) => status === SendStatus.Viewed
        )
      );
    });

    it("returns true if at least one send state matches, even if it's ours", () => {
      const sendStateByConversationId: SendStateByConversationId = {
        abc: {
          status: SendStatus.Sent,
          updatedAt: Date.now(),
        },
        [ourConversationId]: {
          status: SendStatus.Read,
          updatedAt: Date.now(),
        },
        def: {
          status: SendStatus.Delivered,
          updatedAt: Date.now(),
        },
      };

      assert.isTrue(
        someSendStatus(
          sendStateByConversationId,
          (status: SendStatus) => status === SendStatus.Read
        )
      );
    });
  });

  describe('getHighestSuccessfulRecipientStatus', () => {
    const ourConversationId = uuid();
    it('returns pending if the conversation has an empty send state', () => {
      assert.equal(
        getHighestSuccessfulRecipientStatus({}, ourConversationId),
        SendStatus.Pending
      );
    });

    it('returns highest status, excluding our conversation', () => {
      const sendStateByConversationId: SendStateByConversationId = {
        abc: {
          status: SendStatus.Sent,
          updatedAt: Date.now(),
        },
        [ourConversationId]: {
          status: SendStatus.Read,
          updatedAt: Date.now(),
        },
        def: {
          status: SendStatus.Delivered,
          updatedAt: Date.now(),
        },
      };
      assert.equal(
        getHighestSuccessfulRecipientStatus(
          sendStateByConversationId,
          ourConversationId
        ),
        SendStatus.Delivered
      );
    });
  });

  describe('isMessageJustForMe', () => {
    const ourConversationId = uuid();

    it('returns false if the conversation has an empty send state', () => {
      assert.isFalse(isMessageJustForMe({}, ourConversationId));
    });

    it('returns false if the message is for anyone else', () => {
      assert.isFalse(
        isMessageJustForMe(
          {
            [ourConversationId]: {
              status: SendStatus.Sent,
              updatedAt: 123,
            },
            [uuid()]: {
              status: SendStatus.Pending,
              updatedAt: 123,
            },
          },
          ourConversationId
        )
      );

      assert.isFalse(
        isMessageJustForMe(
          {
            [uuid()]: {
              status: SendStatus.Pending,
              updatedAt: 123,
            },
            [ourConversationId]: {
              status: SendStatus.Sent,
              updatedAt: 123,
            },
          },
          ourConversationId
        )
      );
      // This is an invalid state, but we still want to test the behavior.
      assert.isFalse(
        isMessageJustForMe(
          {
            [uuid()]: {
              status: SendStatus.Pending,
              updatedAt: 123,
            },
          },
          ourConversationId
        )
      );
    });

    it('returns true if the message is just for you', () => {
      assert.isTrue(
        isMessageJustForMe(
          {
            [ourConversationId]: {
              status: SendStatus.Sent,
              updatedAt: 123,
            },
          },
          ourConversationId
        )
      );
    });

    it('returns false if the message is for you but we have no conversationId', () => {
      assert.isFalse(
        isMessageJustForMe(
          {
            [ourConversationId]: {
              status: SendStatus.Sent,
              updatedAt: 123,
            },
          },
          undefined
        )
      );
    });
  });

  describe('sendStateReducer', () => {
    const assertTransition = (
      startStatus: SendStatus,
      actionType: SendActionType,
      expectedStatus: SendStatus
    ): void => {
      const startState: SendState = {
        status: startStatus,
        updatedAt: 1,
      };
      const action: SendAction = {
        type: actionType,
        updatedAt: 2,
      };
      const result = sendStateReducer(startState, action);
      assert.strictEqual(result.status, expectedStatus);
      assert.strictEqual(
        result.updatedAt,
        startStatus === expectedStatus ? 1 : 2
      );
    };

    describe('transitions from Pending', () => {
      it('goes from Pending → Failed with a failure', () => {
        const result = sendStateReducer(
          { status: SendStatus.Pending, updatedAt: 999 },
          { type: SendActionType.Failed, updatedAt: 123 }
        );
        assert.deepEqual(result, {
          status: SendStatus.Failed,
          updatedAt: 123,
        });
      });

      it('does nothing when receiving ManuallyRetried', () => {
        assertTransition(
          SendStatus.Pending,
          SendActionType.ManuallyRetried,
          SendStatus.Pending
        );
      });

      it('goes from Pending to all other sent states', () => {
        assertTransition(
          SendStatus.Pending,
          SendActionType.Sent,
          SendStatus.Sent
        );
        assertTransition(
          SendStatus.Pending,
          SendActionType.GotDeliveryReceipt,
          SendStatus.Delivered
        );
        assertTransition(
          SendStatus.Pending,
          SendActionType.GotReadReceipt,
          SendStatus.Read
        );
        assertTransition(
          SendStatus.Pending,
          SendActionType.GotViewedReceipt,
          SendStatus.Viewed
        );
      });
    });

    describe('transitions from Failed', () => {
      it('does nothing when receiving a Failed action', () => {
        const result = sendStateReducer(
          {
            status: SendStatus.Failed,
            updatedAt: 123,
          },
          {
            type: SendActionType.Failed,
            updatedAt: 999,
          }
        );
        assert.deepEqual(result, {
          status: SendStatus.Failed,
          updatedAt: 123,
        });
      });

      it('goes from Failed to all other states', () => {
        assertTransition(
          SendStatus.Failed,
          SendActionType.ManuallyRetried,
          SendStatus.Pending
        );
        assertTransition(
          SendStatus.Failed,
          SendActionType.Sent,
          SendStatus.Sent
        );
        assertTransition(
          SendStatus.Failed,
          SendActionType.GotDeliveryReceipt,
          SendStatus.Delivered
        );
        assertTransition(
          SendStatus.Failed,
          SendActionType.GotReadReceipt,
          SendStatus.Read
        );
        assertTransition(
          SendStatus.Failed,
          SendActionType.GotViewedReceipt,
          SendStatus.Viewed
        );
      });
    });

    describe('transitions from Sent', () => {
      it('does nothing when trying to go "backwards"', () => {
        [SendActionType.Failed, SendActionType.ManuallyRetried].forEach(
          type => {
            assertTransition(SendStatus.Sent, type, SendStatus.Sent);
          }
        );
      });

      it('does nothing when receiving a Sent action', () => {
        assertTransition(SendStatus.Sent, SendActionType.Sent, SendStatus.Sent);
      });

      it('can go forward to other states', () => {
        assertTransition(
          SendStatus.Sent,
          SendActionType.GotDeliveryReceipt,
          SendStatus.Delivered
        );
        assertTransition(
          SendStatus.Sent,
          SendActionType.GotReadReceipt,
          SendStatus.Read
        );
        assertTransition(
          SendStatus.Sent,
          SendActionType.GotViewedReceipt,
          SendStatus.Viewed
        );
      });
    });

    describe('transitions from Delivered', () => {
      it('does nothing when trying to go "backwards"', () => {
        [
          SendActionType.Failed,
          SendActionType.ManuallyRetried,
          SendActionType.Sent,
        ].forEach(type => {
          assertTransition(SendStatus.Delivered, type, SendStatus.Delivered);
        });
      });

      it('does nothing when receiving a delivery receipt', () => {
        assertTransition(
          SendStatus.Delivered,
          SendActionType.GotDeliveryReceipt,
          SendStatus.Delivered
        );
      });

      it('can go forward to other states', () => {
        assertTransition(
          SendStatus.Delivered,
          SendActionType.GotReadReceipt,
          SendStatus.Read
        );
        assertTransition(
          SendStatus.Delivered,
          SendActionType.GotViewedReceipt,
          SendStatus.Viewed
        );
      });
    });

    describe('transitions from Read', () => {
      it('does nothing when trying to go "backwards"', () => {
        [
          SendActionType.Failed,
          SendActionType.ManuallyRetried,
          SendActionType.Sent,
          SendActionType.GotDeliveryReceipt,
        ].forEach(type => {
          assertTransition(SendStatus.Read, type, SendStatus.Read);
        });
      });

      it('does nothing when receiving a read receipt', () => {
        assertTransition(
          SendStatus.Read,
          SendActionType.GotReadReceipt,
          SendStatus.Read
        );
      });

      it('can go forward to the "viewed" state', () => {
        assertTransition(
          SendStatus.Read,
          SendActionType.GotViewedReceipt,
          SendStatus.Viewed
        );
      });
    });

    describe('transitions from Viewed', () => {
      it('ignores all actions', () => {
        [
          SendActionType.Failed,
          SendActionType.ManuallyRetried,
          SendActionType.Sent,
          SendActionType.GotDeliveryReceipt,
          SendActionType.GotReadReceipt,
          SendActionType.GotViewedReceipt,
        ].forEach(type => {
          assertTransition(SendStatus.Viewed, type, SendStatus.Viewed);
        });
      });
    });

    describe('legacy transitions', () => {
      it('allows actions without timestamps', () => {
        const startState: SendState = {
          status: SendStatus.Pending,
          updatedAt: Date.now(),
        };
        const action: SendAction = {
          type: SendActionType.Sent,
          updatedAt: undefined,
        };
        const result = sendStateReducer(startState, action);
        assert.isUndefined(result.updatedAt);
      });
    });
  });
});