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

/* global Signal, Whisper, textsecure, _ */

/* eslint-disable no-console */

'use strict';

describe('Backup', () => {
  describe('_sanitizeFileName', () => {
    it('leaves a basic string alone', () => {
      const initial = "Hello, how are you #5 ('fine' + great).jpg";
      const expected = initial;
      assert.strictEqual(Signal.Backup._sanitizeFileName(initial), expected);
    });

    it('replaces all unknown characters', () => {
      const initial = '!@$%^&*=';
      const expected = '________';
      assert.strictEqual(Signal.Backup._sanitizeFileName(initial), expected);
    });
  });

  describe('_trimFileName', () => {
    it('handles a file with no extension', () => {
      const initial = '0123456789012345678901234567890123456789';
      const expected = '012345678901234567890123456789';
      assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
    });

    it('handles a file with a long extension', () => {
      const initial =
        '0123456789012345678901234567890123456789.01234567890123456789';
      const expected = '012345678901234567890123456789';
      assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
    });

    it('handles a file with a normal extension', () => {
      const initial = '01234567890123456789012345678901234567890123456789.jpg';
      const expected = '012345678901234567890123.jpg';
      assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
    });
  });

  describe('_getExportAttachmentFileName', () => {
    it('uses original filename if attachment has one', () => {
      const message = {
        body: 'something',
      };
      const index = 0;
      const attachment = {
        fileName: 'blah.jpg',
      };
      const expected = 'blah.jpg';

      const actual = Signal.Backup._getExportAttachmentFileName(
        message,
        index,
        attachment
      );
      assert.strictEqual(actual, expected);
    });

    it('uses attachment id if no filename', () => {
      const message = {
        body: 'something',
      };
      const index = 0;
      const attachment = {
        cdnId: '123',
      };
      const expected = '123';

      const actual = Signal.Backup._getExportAttachmentFileName(
        message,
        index,
        attachment
      );
      assert.strictEqual(actual, expected);
    });

    it('uses attachment id and contentType if available', () => {
      const message = {
        body: 'something',
      };
      const index = 0;
      const attachment = {
        cdnId: '123',
        contentType: 'image/jpeg',
      };
      const expected = '123.jpeg';

      const actual = Signal.Backup._getExportAttachmentFileName(
        message,
        index,
        attachment
      );
      assert.strictEqual(actual, expected);
    });

    it('handles strange contentType', () => {
      const message = {
        body: 'something',
      };
      const index = 0;
      const attachment = {
        cdnId: '123',
        contentType: 'something',
      };
      const expected = '123.something';

      const actual = Signal.Backup._getExportAttachmentFileName(
        message,
        index,
        attachment
      );
      assert.strictEqual(actual, expected);
    });

    it('uses CDN key if attachment ID not available', () => {
      const message = {
        body: 'something',
      };
      const index = 0;
      const attachment = {
        cdnKey: 'abc',
      };
      const expected = 'abc';

      const actual = Signal.Backup._getExportAttachmentFileName(
        message,
        index,
        attachment
      );
      assert.strictEqual(actual, expected);
    });

    it('uses CDN key and contentType if available', () => {
      const message = {
        body: 'something',
      };
      const index = 0;
      const attachment = {
        cdnKey: 'def',
        contentType: 'image/jpeg',
      };
      const expected = 'def.jpeg';

      const actual = Signal.Backup._getExportAttachmentFileName(
        message,
        index,
        attachment
      );
      assert.strictEqual(actual, expected);
    });
  });

  describe('_getAnonymousAttachmentFileName', () => {
    it('uses message id', () => {
      const message = {
        id: 'id-45',
        body: 'something',
      };
      const index = 0;
      const attachment = {
        fileName: 'blah.jpg',
      };
      const expected = 'id-45';

      const actual = Signal.Backup._getAnonymousAttachmentFileName(
        message,
        index,
        attachment
      );
      assert.strictEqual(actual, expected);
    });

    it('appends index if it is above zero', () => {
      const message = {
        id: 'id-45',
        body: 'something',
      };
      const index = 1;
      const attachment = {
        fileName: 'blah.jpg',
      };
      const expected = 'id-45-1';

      const actual = Signal.Backup._getAnonymousAttachmentFileName(
        message,
        index,
        attachment
      );
      assert.strictEqual(actual, expected);
    });
  });

  describe('_getConversationDirName', () => {
    it('uses name if available', () => {
      const conversation = {
        active_at: 123,
        name: '0123456789012345678901234567890123456789',
        id: 'id',
      };
      const expected = '123 (012345678901234567890123456789 id)';
      assert.strictEqual(
        Signal.Backup._getConversationDirName(conversation),
        expected
      );
    });

    it('uses just id if name is not available', () => {
      const conversation = {
        active_at: 123,
        id: 'id',
      };
      const expected = '123 (id)';
      assert.strictEqual(
        Signal.Backup._getConversationDirName(conversation),
        expected
      );
    });

    it('uses inactive for missing active_at', () => {
      const conversation = {
        name: 'name',
        id: 'id',
      };
      const expected = 'inactive (name id)';
      assert.strictEqual(
        Signal.Backup._getConversationDirName(conversation),
        expected
      );
    });
  });

  describe('_getConversationLoggingName', () => {
    it('uses plain id if conversation is private', () => {
      const conversation = {
        active_at: 123,
        id: 'id',
        type: 'private',
      };
      const expected = '123 (id)';
      assert.strictEqual(
        Signal.Backup._getConversationLoggingName(conversation),
        expected
      );
    });

    it('uses just id if name is not available', () => {
      const conversation = {
        active_at: 123,
        id: 'groupId',
        type: 'group',
      };
      const expected = '123 ([REDACTED_GROUP]pId)';
      assert.strictEqual(
        Signal.Backup._getConversationLoggingName(conversation),
        expected
      );
    });

    it('uses inactive for missing active_at', () => {
      const conversation = {
        id: 'id',
        type: 'private',
      };
      const expected = 'inactive (id)';
      assert.strictEqual(
        Signal.Backup._getConversationLoggingName(conversation),
        expected
      );
    });
  });

  describe('end-to-end', () => {
    it('exports then imports to produce the same data we started with', async function thisNeeded() {
      this.timeout(6000);

      const {
        attachmentsPath,
        fse,
        fastGlob,
        normalizePath,
        path,
        tmp,
      } = window.test;
      const {
        upgradeMessageSchema,
        loadAttachmentData,
      } = window.Signal.Migrations;

      const staticKeyPair = window.Signal.Curve.generateKeyPair();
      const attachmentsPattern = normalizePath(
        path.join(attachmentsPath, '**')
      );

      const OUR_NUMBER = '+12025550000';
      const CONTACT_ONE_NUMBER = '+12025550001';
      const CONTACT_TWO_NUMBER = '+12025550002';

      const CONVERSATION_ID = 'bdaa7f4f-e9bd-493e-ab0d-8331ad604269';

      const getFixture = target =>
        window.Signal.Crypto.typedArrayToArrayBuffer(fse.readFileSync(target));

      const FIXTURES = {
        gif: getFixture('fixtures/giphy-7GFfijngKbeNy.gif'),
        mp4: getFixture('fixtures/pixabay-Soap-Bubble-7141.mp4'),
        jpg: getFixture('fixtures/koushik-chowdavarapu-105425-unsplash.jpg'),
        mp3: getFixture('fixtures/incompetech-com-Agnus-Dei-X.mp3'),
        txt: getFixture('fixtures/lorem-ipsum.txt'),
        png: getFixture(
          'fixtures/freepngs-2cd43b_bed7d1327e88454487397574d87b64dc_mv2.png'
        ),
      };

      async function wrappedLoadAttachment(attachment) {
        return _.omit(await loadAttachmentData(attachment), ['path']);
      }

      async function clearAllData() {
        await textsecure.storage.protocol.removeAllData();
        await fse.emptyDir(attachmentsPath);
      }

      function removeId(model) {
        return _.omit(model, ['id']);
      }

      const getUndefinedKeys = object =>
        Object.entries(object)
          .filter(([, value]) => value === undefined)
          .map(([name]) => name);
      const omitUndefinedKeys = object =>
        _.omit(object, getUndefinedKeys(object));

      // We want to know which paths have two slashes, since that tells us which files
      //   in the attachment fan-out are files vs. directories.
      const TWO_SLASHES = /[^/]*\/[^/]*\/[^/]*/;
      // On windows, attachmentsPath has a normal windows path format (\ separators), but
      //   glob returns only /. We normalize to / separators for our manipulations.
      const normalizedBase = attachmentsPath.replace(/\\/g, '/');
      function removeDirs(dirs) {
        return _.filter(dirs, fullDir => {
          const dir = fullDir.replace(normalizedBase, '');
          return TWO_SLASHES.test(dir);
        });
      }

      function _mapQuotedAttachments(mapper) {
        return async (message, context) => {
          if (!message.quote) {
            return message;
          }

          const wrappedMapper = async attachment => {
            if (!attachment || !attachment.thumbnail) {
              return attachment;
            }

            return {
              ...attachment,
              thumbnail: await mapper(attachment.thumbnail, context),
            };
          };

          const quotedAttachments =
            (message.quote && message.quote.attachments) || [];

          return {
            ...message,
            quote: {
              ...message.quote,
              attachments: await Promise.all(
                quotedAttachments.map(wrappedMapper)
              ),
            },
          };
        };
      }

      async function loadAllFilesFromDisk(message) {
        const loadThumbnails = _mapQuotedAttachments(thumbnail => {
          // we want to be bulletproof to thumbnails without data
          if (!thumbnail.path) {
            return thumbnail;
          }

          return wrappedLoadAttachment(thumbnail);
        });

        return {
          ...(await loadThumbnails(message)),
          contact: await Promise.all(
            (message.contact || []).map(async contact => {
              return contact && contact.avatar && contact.avatar.avatar
                ? {
                    ...contact,
                    avatar: {
                      ...contact.avatar,
                      avatar: await wrappedLoadAttachment(
                        contact.avatar.avatar
                      ),
                    },
                  }
                : contact;
            })
          ),
          attachments: await Promise.all(
            (message.attachments || []).map(async attachment => {
              await wrappedLoadAttachment(attachment);

              if (attachment.thumbnail) {
                await wrappedLoadAttachment(attachment.thumbnail);
              }

              if (attachment.screenshot) {
                await wrappedLoadAttachment(attachment.screenshot);
              }

              return attachment;
            })
          ),
          preview: await Promise.all(
            (message.preview || []).map(async item => {
              if (item.image) {
                await wrappedLoadAttachment(item.image);
              }

              return item;
            })
          ),
        };
      }

      let backupDir;
      try {
        // Seven total:
        //   - Five from image/video attachments
        //   - One from embedded contact avatar
        //   - One from embedded quoted attachment thumbnail
        //   - One from a link preview image
        const ATTACHMENT_COUNT = 8;
        const MESSAGE_COUNT = 1;
        const CONVERSATION_COUNT = 1;

        const messageWithAttachments = {
          conversationId: CONVERSATION_ID,
          body: 'Totally!',
          source: OUR_NUMBER,
          received_at: 1524185933350,
          sent_at: 1524185933350,
          timestamp: 1524185933350,
          errors: [],
          attachments: [
            // Note: generates two more files: screenshot and thumbnail
            {
              contentType: 'video/mp4',
              fileName: 'video.mp4',
              data: FIXTURES.mp4,
            },
            // Note: generates one more file: thumbnail
            {
              contentType: 'image/png',
              fileName: 'landscape.png',
              data: FIXTURES.png,
            },
          ],
          hasAttachments: 1,
          hasVisualMediaAttachments: 1,
          quote: {
            text: "Isn't it cute?",
            author: CONTACT_ONE_NUMBER,
            id: 12345678,
            attachments: [
              {
                contentType: 'audio/mp3',
                fileName: 'song.mp3',
              },
              {
                contentType: 'image/gif',
                fileName: 'avatar.gif',
                thumbnail: {
                  contentType: 'image/png',
                  data: FIXTURES.gif,
                },
              },
            ],
          },
          contact: [
            {
              name: {
                displayName: 'Someone Somewhere',
              },
              number: [
                {
                  value: CONTACT_TWO_NUMBER,
                  type: 1,
                },
              ],
              avatar: {
                isProfile: false,
                avatar: {
                  contentType: 'image/png',
                  data: FIXTURES.png,
                },
              },
            },
          ],
          preview: [
            {
              url: 'https://www.instagram.com/p/BsOGulcndj-/',
              title:
                'EGG GANG 🌍 on Instagram: “Let’s set a world record together and get the most liked post on Instagram. Beating the current world record held by Kylie Jenner (18…”',
              image: {
                contentType: 'image/jpeg',
                data: FIXTURES.jpg,
              },
            },
          ],
        };

        console.log('Backup test: Clear all data');
        await clearAllData();

        console.log('Backup test: Create models, save to db/disk');
        const message = await upgradeMessageSchema(messageWithAttachments);
        console.log({ message });
        await window.Signal.Data.saveMessage(message, {
          Message: Whisper.Message,
        });

        const conversation = {
          active_at: 1524185933350,
          color: 'orange',
          expireTimer: 0,
          id: CONVERSATION_ID,
          name: 'Someone Somewhere',
          profileAvatar: {
            contentType: 'image/jpeg',
            data: FIXTURES.jpeg,
            size: 64,
          },
          profileKey: 'BASE64KEY',
          profileName: 'Someone! 🤔',
          profileSharing: true,
          profileLastFetchedAt: 1524185933350,
          timestamp: 1524185933350,
          type: 'private',
          unreadCount: 0,
          messageCount: 0,
          sentMessageCount: 0,
          verified: 0,
          sealedSender: 0,
          version: 2,
        };
        console.log({ conversation });
        await window.Signal.Data.saveConversation(conversation, {
          Conversation: Whisper.Conversation,
        });

        console.log(
          'Backup test: Ensure that all attachments were saved to disk'
        );
        const attachmentFiles = removeDirs(fastGlob.sync(attachmentsPattern));
        console.log({ attachmentFiles });
        assert.strictEqual(ATTACHMENT_COUNT, attachmentFiles.length);

        console.log('Backup test: Export!');
        backupDir = tmp.dirSync().name;
        console.log({ backupDir });
        await Signal.Backup.exportToDirectory(backupDir, {
          key: staticKeyPair.pubKey,
        });

        console.log('Backup test: Ensure that messages.tar.gz exists');
        const archivePath = path.join(backupDir, 'messages.tar.gz');
        const messageZipExists = fse.existsSync(archivePath);
        assert.strictEqual(true, messageZipExists);

        console.log(
          'Backup test: Ensure that all attachments made it to backup dir'
        );
        const backupAttachmentPattern = normalizePath(
          path.join(backupDir, 'attachments/*')
        );
        const backupAttachments = fastGlob.sync(backupAttachmentPattern);
        console.log({ backupAttachments });
        assert.strictEqual(ATTACHMENT_COUNT, backupAttachments.length);

        console.log('Backup test: Clear all data');
        await clearAllData();

        console.log('Backup test: Import!');
        await Signal.Backup.importFromDirectory(backupDir, {
          key: staticKeyPair.privKey,
        });

        console.log('Backup test: Check conversations');
        const conversationCollection = await window.Signal.Data.getAllConversations(
          {
            ConversationCollection: Whisper.ConversationCollection,
          }
        );
        assert.strictEqual(conversationCollection.length, CONVERSATION_COUNT);

        const conversationFromDB = conversationCollection.at(0).attributes;
        console.log({ conversationFromDB, conversation });
        assert.deepEqual(
          conversationFromDB,
          _.omit(conversation, ['profileAvatar'])
        );

        console.log('Backup test: Check messages');
        const messageCollection = await window.Signal.Data._getAllMessages({
          MessageCollection: Whisper.MessageCollection,
        });
        assert.strictEqual(messageCollection.length, MESSAGE_COUNT);
        const messageFromDB = removeId(messageCollection.at(0).attributes);
        const expectedMessage = messageFromDB;
        console.log({ messageFromDB, expectedMessage });
        assert.deepEqual(messageFromDB, expectedMessage);

        console.log('Backup test: Ensure that all attachments were imported');
        const recreatedAttachmentFiles = removeDirs(
          fastGlob.sync(attachmentsPattern)
        );
        console.log({ recreatedAttachmentFiles });
        assert.strictEqual(ATTACHMENT_COUNT, recreatedAttachmentFiles.length);
        assert.deepEqual(attachmentFiles, recreatedAttachmentFiles);

        console.log(
          'Backup test: Check that all attachments were successfully imported'
        );
        const messageWithAttachmentsFromDB = await loadAllFilesFromDisk(
          omitUndefinedKeys(messageFromDB)
        );
        const expectedMessageWithAttachments = await loadAllFilesFromDisk(
          omitUndefinedKeys(message)
        );
        console.log({
          messageWithAttachmentsFromDB,
          expectedMessageWithAttachments,
        });
        assert.deepEqual(
          messageWithAttachmentsFromDB,
          expectedMessageWithAttachments
        );

        console.log('Backup test: Clear all data');
        await clearAllData();

        console.log('Backup test: Complete!');
      } finally {
        if (backupDir) {
          console.log({ backupDir });
          console.log('Deleting', backupDir);
          await fse.remove(backupDir);
        }
      }
    });
  });
});
