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

const { isFunction, isObject, isString, omit } = require('lodash');

const Contact = require('../../../ts/types/EmbeddedContact');
const Attachment = require('../../../ts/types/Attachment');
const Errors = require('../../../ts/types/errors');
const SchemaVersion = require('../../../ts/types/SchemaVersion');
const {
  initializeAttachmentMetadata,
} = require('../../../ts/types/message/initializeAttachmentMetadata');
const MessageTS = require('../../../ts/types/Message');

const GROUP = 'group';
const PRIVATE = 'private';

// Schema version history
//
// Version 0
//   - Schema initialized
// Version 1
//   - Attachments: Auto-orient JPEG attachments using EXIF `Orientation` data.
//     N.B. The process of auto-orient for JPEGs strips (loses) all existing
//     EXIF metadata improving privacy, e.g. geolocation, camera make, etc.
// Version 2
//   - Attachments: Sanitize Unicode order override characters.
// Version 3
//   - Attachments: Write attachment data to disk and store relative path to it.
// Version 4
//   - Quotes: Write thumbnail data to disk and store relative path to it.
// Version 5 (deprecated)
//   - Attachments: Track number and kind of attachments for media gallery
//     - `hasAttachments?: 1 | 0`
//     - `hasVisualMediaAttachments?: 1 | undefined` (for media gallery ‘Media’ view)
//     - `hasFileAttachments?: 1 | undefined` (for media gallery ‘Documents’ view)
//   - IMPORTANT: Version 7 changes the classification of visual media and files.
//     Therefore version 5 is considered deprecated. For an easier implementation,
//     new files have the same classification in version 5 as in version 7.
// Version 6
//   - Contact: Write contact avatar to disk, ensure contact data is well-formed
// Version 7 (supersedes attachment classification in version 5)
//   - Attachments: Update classification for:
//     - `hasVisualMediaAttachments`: Include all images and video regardless of
//       whether Chromium can render it or not.
//     - `hasFileAttachments`: Exclude voice messages.
// Version 8
//   - Attachments: Capture video/image dimensions and thumbnails, as well as a
//       full-size screenshot for video.
// Version 9
//   - Attachments: Expand the set of unicode characters we filter out of
//     attachment filenames
// Version 10
//   - Preview: A new type of attachment can be included in a message.

const INITIAL_SCHEMA_VERSION = 0;

// Public API
exports.GROUP = GROUP;
exports.PRIVATE = PRIVATE;

// Placeholder until we have stronger preconditions:
exports.isValid = () => true;

// Schema
exports.initializeSchemaVersion = ({ message, logger }) => {
  const isInitialized =
    SchemaVersion.isValid(message.schemaVersion) && message.schemaVersion >= 1;
  if (isInitialized) {
    return message;
  }

  const numAttachments = Array.isArray(message.attachments)
    ? message.attachments.length
    : 0;
  const hasAttachments = numAttachments > 0;
  if (!hasAttachments) {
    return { ...message, schemaVersion: INITIAL_SCHEMA_VERSION };
  }

  // All attachments should have the same schema version, so we just pick
  // the first one:
  const firstAttachment = message.attachments[0];
  const inheritedSchemaVersion = SchemaVersion.isValid(
    firstAttachment.schemaVersion
  )
    ? firstAttachment.schemaVersion
    : INITIAL_SCHEMA_VERSION;
  const messageWithInitialSchema = {
    ...message,
    schemaVersion: inheritedSchemaVersion,
    attachments: message.attachments.map(attachment =>
      Attachment.removeSchemaVersion({ attachment, logger })
    ),
  };

  return messageWithInitialSchema;
};

// Middleware
// type UpgradeStep = (Message, Context) -> Promise Message

// SchemaVersion -> UpgradeStep -> UpgradeStep
exports._withSchemaVersion = ({ schemaVersion, upgrade }) => {
  if (!SchemaVersion.isValid(schemaVersion)) {
    throw new TypeError('_withSchemaVersion: schemaVersion is invalid');
  }
  if (!isFunction(upgrade)) {
    throw new TypeError('_withSchemaVersion: upgrade must be a function');
  }

  return async (message, context) => {
    if (!context || !isObject(context.logger)) {
      throw new TypeError(
        '_withSchemaVersion: context must have logger object'
      );
    }
    const { logger } = context;

    if (!exports.isValid(message)) {
      logger.error(
        'Message._withSchemaVersion: Invalid input message:',
        message
      );
      return message;
    }

    const isAlreadyUpgraded = message.schemaVersion >= schemaVersion;
    if (isAlreadyUpgraded) {
      return message;
    }

    const expectedVersion = schemaVersion - 1;
    const hasExpectedVersion = message.schemaVersion === expectedVersion;
    if (!hasExpectedVersion) {
      logger.warn(
        'WARNING: Message._withSchemaVersion: Unexpected version:',
        `Expected message to have version ${expectedVersion},`,
        `but got ${message.schemaVersion}.`
      );
      return message;
    }

    let upgradedMessage;
    try {
      upgradedMessage = await upgrade(message, context);
    } catch (error) {
      logger.error(
        `Message._withSchemaVersion: error updating message ${message.id}:`,
        Errors.toLogFormat(error)
      );
      return message;
    }

    if (!exports.isValid(upgradedMessage)) {
      logger.error(
        'Message._withSchemaVersion: Invalid upgraded message:',
        upgradedMessage
      );
      return message;
    }

    return { ...upgradedMessage, schemaVersion };
  };
};

// Public API
//      _mapAttachments :: (Attachment -> Promise Attachment) ->
//                         (Message, Context) ->
//                         Promise Message
exports._mapAttachments = upgradeAttachment => async (message, context) => {
  const upgradeWithContext = attachment =>
    upgradeAttachment(attachment, context, message);
  const attachments = await Promise.all(
    (message.attachments || []).map(upgradeWithContext)
  );
  return { ...message, attachments };
};

// Public API
//      _mapContact :: (Contact -> Promise Contact) ->
//                     (Message, Context) ->
//                     Promise Message
exports._mapContact = upgradeContact => async (message, context) => {
  const contextWithMessage = { ...context, message };
  const upgradeWithContext = contact =>
    upgradeContact(contact, contextWithMessage);
  const contact = await Promise.all(
    (message.contact || []).map(upgradeWithContext)
  );
  return { ...message, contact };
};

//      _mapQuotedAttachments :: (QuotedAttachment -> Promise QuotedAttachment) ->
//                               (Message, Context) ->
//                               Promise Message
exports._mapQuotedAttachments =
  upgradeAttachment => async (message, context) => {
    if (!message.quote) {
      return message;
    }
    if (!context || !isObject(context.logger)) {
      throw new Error('_mapQuotedAttachments: context must have logger object');
    }

    const upgradeWithContext = async attachment => {
      const { thumbnail } = attachment;
      if (!thumbnail) {
        return attachment;
      }

      const upgradedThumbnail = await upgradeAttachment(thumbnail, context);
      return { ...attachment, thumbnail: upgradedThumbnail };
    };

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

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

//      _mapPreviewAttachments :: (PreviewAttachment -> Promise PreviewAttachment) ->
//                               (Message, Context) ->
//                               Promise Message
exports._mapPreviewAttachments =
  upgradeAttachment => async (message, context) => {
    if (!message.preview) {
      return message;
    }
    if (!context || !isObject(context.logger)) {
      throw new Error(
        '_mapPreviewAttachments: context must have logger object'
      );
    }

    const upgradeWithContext = async preview => {
      const { image } = preview;
      if (!image) {
        return preview;
      }

      const upgradedImage = await upgradeAttachment(image, context);
      return { ...preview, image: upgradedImage };
    };

    const preview = await Promise.all(
      (message.preview || []).map(upgradeWithContext)
    );
    return { ...message, preview };
  };

const toVersion0 = async (message, context) =>
  exports.initializeSchemaVersion({ message, logger: context.logger });
const toVersion1 = exports._withSchemaVersion({
  schemaVersion: 1,
  upgrade: exports._mapAttachments(Attachment.autoOrientJPEG),
});
const toVersion2 = exports._withSchemaVersion({
  schemaVersion: 2,
  upgrade: exports._mapAttachments(Attachment.replaceUnicodeOrderOverrides),
});
const toVersion3 = exports._withSchemaVersion({
  schemaVersion: 3,
  upgrade: exports._mapAttachments(Attachment.migrateDataToFileSystem),
});
const toVersion4 = exports._withSchemaVersion({
  schemaVersion: 4,
  upgrade: exports._mapQuotedAttachments(Attachment.migrateDataToFileSystem),
});
const toVersion5 = exports._withSchemaVersion({
  schemaVersion: 5,
  upgrade: initializeAttachmentMetadata,
});
const toVersion6 = exports._withSchemaVersion({
  schemaVersion: 6,
  upgrade: exports._mapContact(
    Contact.parseAndWriteAvatar(Attachment.migrateDataToFileSystem)
  ),
});
// IMPORTANT: We’ve updated our definition of `initializeAttachmentMetadata`, so
// we need to run it again on existing items that have previously been incorrectly
// classified:
const toVersion7 = exports._withSchemaVersion({
  schemaVersion: 7,
  upgrade: initializeAttachmentMetadata,
});

const toVersion8 = exports._withSchemaVersion({
  schemaVersion: 8,
  upgrade: exports._mapAttachments(Attachment.captureDimensionsAndScreenshot),
});

const toVersion9 = exports._withSchemaVersion({
  schemaVersion: 9,
  upgrade: exports._mapAttachments(Attachment.replaceUnicodeV2),
});
const toVersion10 = exports._withSchemaVersion({
  schemaVersion: 10,
  upgrade: async (message, context) => {
    const processPreviews = exports._mapPreviewAttachments(
      Attachment.migrateDataToFileSystem
    );
    const processSticker = async (stickerMessage, stickerContext) => {
      const { sticker } = stickerMessage;
      if (!sticker || !sticker.data || !sticker.data.data) {
        return stickerMessage;
      }

      return {
        ...stickerMessage,
        sticker: {
          ...sticker,
          data: await Attachment.migrateDataToFileSystem(
            sticker.data,
            stickerContext
          ),
        },
      };
    };

    const previewProcessed = await processPreviews(message, context);
    const stickerProcessed = await processSticker(previewProcessed, context);

    return stickerProcessed;
  },
});

const VERSIONS = [
  toVersion0,
  toVersion1,
  toVersion2,
  toVersion3,
  toVersion4,
  toVersion5,
  toVersion6,
  toVersion7,
  toVersion8,
  toVersion9,
  toVersion10,
];
exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1;

// We need dimensions and screenshots for images for proper display
exports.VERSION_NEEDED_FOR_DISPLAY = 9;

// UpgradeStep
exports.upgradeSchema = async (
  rawMessage,
  {
    writeNewAttachmentData,
    getRegionCode,
    getAbsoluteAttachmentPath,
    makeObjectUrl,
    revokeObjectUrl,
    getImageDimensions,
    makeImageThumbnail,
    makeVideoScreenshot,
    logger,
    maxVersion = exports.CURRENT_SCHEMA_VERSION,
  } = {}
) => {
  if (!isFunction(writeNewAttachmentData)) {
    throw new TypeError('context.writeNewAttachmentData is required');
  }
  if (!isFunction(getRegionCode)) {
    throw new TypeError('context.getRegionCode is required');
  }
  if (!isFunction(getAbsoluteAttachmentPath)) {
    throw new TypeError('context.getAbsoluteAttachmentPath is required');
  }
  if (!isFunction(makeObjectUrl)) {
    throw new TypeError('context.makeObjectUrl is required');
  }
  if (!isFunction(revokeObjectUrl)) {
    throw new TypeError('context.revokeObjectUrl is required');
  }
  if (!isFunction(getImageDimensions)) {
    throw new TypeError('context.getImageDimensions is required');
  }
  if (!isFunction(makeImageThumbnail)) {
    throw new TypeError('context.makeImageThumbnail is required');
  }
  if (!isFunction(makeVideoScreenshot)) {
    throw new TypeError('context.makeVideoScreenshot is required');
  }
  if (!isObject(logger)) {
    throw new TypeError('context.logger is required');
  }

  let message = rawMessage;
  for (let index = 0, max = VERSIONS.length; index < max; index += 1) {
    if (maxVersion < index) {
      break;
    }

    const currentVersion = VERSIONS[index];
    // We really do want this intra-loop await because this is a chained async action,
    //   each step dependent on the previous
    // eslint-disable-next-line no-await-in-loop
    message = await currentVersion(message, {
      writeNewAttachmentData,
      regionCode: getRegionCode(),
      getAbsoluteAttachmentPath,
      makeObjectUrl,
      revokeObjectUrl,
      getImageDimensions,
      makeImageThumbnail,
      makeVideoScreenshot,
      logger,
    });
  }

  return message;
};

// Runs on attachments outside of the schema upgrade process, since attachments are
//   downloaded out of band.
exports.processNewAttachment = async (
  attachment,
  {
    writeNewAttachmentData,
    getAbsoluteAttachmentPath,
    makeObjectUrl,
    revokeObjectUrl,
    getImageDimensions,
    makeImageThumbnail,
    makeVideoScreenshot,
    logger,
  } = {}
) => {
  if (!isFunction(writeNewAttachmentData)) {
    throw new TypeError('context.writeNewAttachmentData is required');
  }
  if (!isFunction(getAbsoluteAttachmentPath)) {
    throw new TypeError('context.getAbsoluteAttachmentPath is required');
  }
  if (!isFunction(makeObjectUrl)) {
    throw new TypeError('context.makeObjectUrl is required');
  }
  if (!isFunction(revokeObjectUrl)) {
    throw new TypeError('context.revokeObjectUrl is required');
  }
  if (!isFunction(getImageDimensions)) {
    throw new TypeError('context.getImageDimensions is required');
  }
  if (!isFunction(makeImageThumbnail)) {
    throw new TypeError('context.makeImageThumbnail is required');
  }
  if (!isFunction(makeVideoScreenshot)) {
    throw new TypeError('context.makeVideoScreenshot is required');
  }
  if (!isObject(logger)) {
    throw new TypeError('context.logger is required');
  }

  const rotatedAttachment = await Attachment.autoOrientJPEG(
    attachment,
    undefined,
    { isIncoming: true }
  );
  const onDiskAttachment = await Attachment.migrateDataToFileSystem(
    rotatedAttachment,
    { writeNewAttachmentData }
  );
  const finalAttachment = await Attachment.captureDimensionsAndScreenshot(
    onDiskAttachment,
    {
      writeNewAttachmentData,
      getAbsoluteAttachmentPath,
      makeObjectUrl,
      revokeObjectUrl,
      getImageDimensions,
      makeImageThumbnail,
      makeVideoScreenshot,
      logger,
    }
  );

  return finalAttachment;
};

exports.processNewSticker = async (
  stickerData,
  {
    writeNewStickerData,
    getAbsoluteStickerPath,
    getImageDimensions,
    logger,
  } = {}
) => {
  if (!isFunction(writeNewStickerData)) {
    throw new TypeError('context.writeNewStickerData is required');
  }
  if (!isFunction(getAbsoluteStickerPath)) {
    throw new TypeError('context.getAbsoluteStickerPath is required');
  }
  if (!isFunction(getImageDimensions)) {
    throw new TypeError('context.getImageDimensions is required');
  }
  if (!isObject(logger)) {
    throw new TypeError('context.logger is required');
  }

  const path = await writeNewStickerData(stickerData);
  const absolutePath = await getAbsoluteStickerPath(path);

  const { width, height } = await getImageDimensions({
    objectUrl: absolutePath,
    logger,
  });

  return {
    path,
    width,
    height,
  };
};

exports.createAttachmentLoader = loadAttachmentData => {
  if (!isFunction(loadAttachmentData)) {
    throw new TypeError(
      'createAttachmentLoader: loadAttachmentData is required'
    );
  }

  return async message => ({
    ...message,
    attachments: await Promise.all(message.attachments.map(loadAttachmentData)),
  });
};

exports.loadQuoteData = loadAttachmentData => {
  if (!isFunction(loadAttachmentData)) {
    throw new TypeError('loadQuoteData: loadAttachmentData is required');
  }

  return async quote => {
    if (!quote) {
      return null;
    }

    return {
      ...quote,
      attachments: await Promise.all(
        (quote.attachments || []).map(async attachment => {
          const { thumbnail } = attachment;

          if (!thumbnail || !thumbnail.path) {
            return attachment;
          }

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

exports.loadPreviewData = loadAttachmentData => {
  if (!isFunction(loadAttachmentData)) {
    throw new TypeError('loadPreviewData: loadAttachmentData is required');
  }

  return async preview => {
    if (!preview || !preview.length) {
      return [];
    }

    return Promise.all(
      preview.map(async item => {
        if (!item.image) {
          return item;
        }

        return {
          ...item,
          image: await loadAttachmentData(item.image),
        };
      })
    );
  };
};

exports.loadStickerData = loadAttachmentData => {
  if (!isFunction(loadAttachmentData)) {
    throw new TypeError('loadStickerData: loadAttachmentData is required');
  }

  return async sticker => {
    if (!sticker || !sticker.data) {
      return null;
    }

    return {
      ...sticker,
      data: await loadAttachmentData(sticker.data),
    };
  };
};

exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
  if (!isFunction(deleteAttachmentData)) {
    throw new TypeError(
      'deleteAllExternalFiles: deleteAttachmentData must be a function'
    );
  }

  if (!isFunction(deleteOnDisk)) {
    throw new TypeError(
      'deleteAllExternalFiles: deleteOnDisk must be a function'
    );
  }

  return async message => {
    const { attachments, quote, contact, preview, sticker } = message;

    if (attachments && attachments.length) {
      await Promise.all(attachments.map(deleteAttachmentData));
    }

    if (quote && quote.attachments && quote.attachments.length) {
      await Promise.all(
        quote.attachments.map(async attachment => {
          const { thumbnail } = attachment;

          // To prevent spoofing, we copy the original image from the quoted message.
          //   If so, it will have a 'copied' field. We don't want to delete it if it has
          //   that field set to true.
          if (thumbnail && thumbnail.path && !thumbnail.copied) {
            await deleteOnDisk(thumbnail.path);
          }
        })
      );
    }

    if (contact && contact.length) {
      await Promise.all(
        contact.map(async item => {
          const { avatar } = item;

          if (avatar && avatar.avatar && avatar.avatar.path) {
            await deleteOnDisk(avatar.avatar.path);
          }
        })
      );
    }

    if (preview && preview.length) {
      await Promise.all(
        preview.map(async item => {
          const { image } = item;

          if (image && image.path) {
            await deleteOnDisk(image.path);
          }
        })
      );
    }

    if (sticker && sticker.data && sticker.data.path) {
      await deleteOnDisk(sticker.data.path);

      if (sticker.data.thumbnail && sticker.data.thumbnail.path) {
        await deleteOnDisk(sticker.data.thumbnail.path);
      }
    }
  };
};

//      createAttachmentDataWriter :: (RelativePath -> IO Unit)
//                                    Message ->
//                                    IO (Promise Message)
exports.createAttachmentDataWriter = ({
  writeExistingAttachmentData,
  logger,
}) => {
  if (!isFunction(writeExistingAttachmentData)) {
    throw new TypeError(
      'createAttachmentDataWriter: writeExistingAttachmentData must be a function'
    );
  }
  if (!isObject(logger)) {
    throw new TypeError('createAttachmentDataWriter: logger must be an object');
  }

  return async rawMessage => {
    if (!exports.isValid(rawMessage)) {
      throw new TypeError("'rawMessage' is not valid");
    }

    const message = exports.initializeSchemaVersion({
      message: rawMessage,
      logger,
    });

    const { attachments, quote, contact, preview } = message;
    const hasFilesToWrite =
      (quote && quote.attachments && quote.attachments.length > 0) ||
      (attachments && attachments.length > 0) ||
      (contact && contact.length > 0) ||
      (preview && preview.length > 0);

    if (!hasFilesToWrite) {
      return message;
    }

    const lastVersionWithAttachmentDataInMemory = 2;
    const willAttachmentsGoToFileSystemOnUpgrade =
      message.schemaVersion <= lastVersionWithAttachmentDataInMemory;
    if (willAttachmentsGoToFileSystemOnUpgrade) {
      return message;
    }

    (attachments || []).forEach(attachment => {
      if (!Attachment.hasData(attachment)) {
        throw new TypeError(
          "'attachment.data' is required during message import"
        );
      }

      if (!isString(attachment.path)) {
        throw new TypeError(
          "'attachment.path' is required during message import"
        );
      }
    });

    const writeThumbnails = exports._mapQuotedAttachments(async thumbnail => {
      const { data, path } = thumbnail;

      // we want to be bulletproof to thumbnails without data
      if (!data || !path) {
        logger.warn(
          'Thumbnail had neither data nor path.',
          'id:',
          message.id,
          'source:',
          message.source
        );
        return thumbnail;
      }

      await writeExistingAttachmentData(thumbnail);
      return omit(thumbnail, ['data']);
    });

    const writeContactAvatar = async messageContact => {
      const { avatar } = messageContact;
      if (avatar && !avatar.avatar) {
        return omit(messageContact, ['avatar']);
      }

      await writeExistingAttachmentData(avatar.avatar);

      return {
        ...messageContact,
        avatar: { ...avatar, avatar: omit(avatar.avatar, ['data']) },
      };
    };

    const writePreviewImage = async item => {
      const { image } = item;
      if (!image) {
        return omit(item, ['image']);
      }

      await writeExistingAttachmentData(image);

      return { ...item, image: omit(image, ['data']) };
    };

    const messageWithoutAttachmentData = {
      ...(await writeThumbnails(message, { logger })),
      contact: await Promise.all((contact || []).map(writeContactAvatar)),
      preview: await Promise.all((preview || []).map(writePreviewImage)),
      attachments: await Promise.all(
        (attachments || []).map(async attachment => {
          await writeExistingAttachmentData(attachment);

          if (attachment.screenshot && attachment.screenshot.data) {
            await writeExistingAttachmentData(attachment.screenshot);
          }
          if (attachment.thumbnail && attachment.thumbnail.data) {
            await writeExistingAttachmentData(attachment.thumbnail);
          }

          return {
            ...omit(attachment, ['data']),
            ...(attachment.thumbnail
              ? { thumbnail: omit(attachment.thumbnail, ['data']) }
              : null),
            ...(attachment.screenshot
              ? { screenshot: omit(attachment.screenshot, ['data']) }
              : null),
          };
        })
      ),
    };

    return messageWithoutAttachmentData;
  };
};

exports.hasExpiration = MessageTS.hasExpiration;
