text: // disable no-return-await for model functions
  /* eslint-disable no-return-await */
  
  const flatMap = require('array.prototype.flatmap');
  
  const mongoose = require('mongoose');
  const mongoosePaginate = require('mongoose-paginate-v2');
  const uniqueValidator = require('mongoose-unique-validator');
  
  const ObjectId = mongoose.Schema.Types.ObjectId;
  
  
  /*
   * define schema
   */
  const schema = new mongoose.Schema({
    relatedPage: {
      type: ObjectId,
      ref: 'Page',
      required: true,
      index: true,
    },
    relatedTag: {
      type: ObjectId,
      ref: 'Tag',
      required: true,
      index: true,
    },
    isPageTrashed: {
      type: Boolean,
      default: false,
      required: true,
      index: true,
    },
  });
  // define unique compound index
  schema.index({ relatedPage: 1, relatedTag: 1 }, { unique: true });
  schema.plugin(mongoosePaginate);
  schema.plugin(uniqueValidator);
  
  /**
   * PageTagRelation Class
   *
   * @class PageTagRelation
   */
  class PageTagRelation {
  
    static async createTagListWithCount(option) {
      const opt = option || {};
      const sortOpt = opt.sortOpt || {};
      const offset = opt.offset;
      const limit = opt.limit;
  
      const tags = await this.aggregate()
        .match({ isPageTrashed: false })
        .lookup({
          from: 'tags',
          localField: 'relatedTag',
          foreignField: '_id',
          as: 'tag',
        })
        .unwind('$tag')
        .group({ _id: '$relatedTag', count: { $sum: 1 }, name: { $first: '$tag.name' } })
        .sort(sortOpt)
        .skip(offset)
        .limit(limit);
  
      const totalCount = (await this.find({ isPageTrashed: false }).distinct('relatedTag')).length;
  
      return { data: tags, totalCount };
    }
  
    static async findByPageId(pageId, options = {}) {
      const isAcceptRelatedTagNull = options.nullable || null;
      const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('relatedTag');
      return isAcceptRelatedTagNull ? relations : relations.filter((relation) => { return relation.relatedTag !== null });
    }
  
    static async listTagNamesByPage(pageId) {
      const relations = await this.findByPageId(pageId);
      return relations.map((relation) => { return relation.relatedTag.name });
    }
  
    /**
     * @return {object} key: Page._id, value: array of tag names
     */
    static async getIdToTagNamesMap(pageIds) {
      /**
       * @see https://docs.mongodb.com/manual/reference/operator/aggregation/group/#pivot-data
       *
       * results will be:
       * [
       *   { _id: 58dca7b2c435b3480098dbbc, tagIds: [ 5da630f71a677515601e36d7, 5da77163ec786e4fe43e0e3e ]},
       *   { _id: 58dca7b2c435b3480098dbbd, tagIds: [ ... ]},
       *   ...
       * ]
       */
      const results = await this.aggregate()
        .match({ relatedPage: { $in: pageIds } })
        .group({ _id: '$relatedPage', tagIds: { $push: '$relatedTag' } });
  
      if (results.length === 0) {
        return {};
      }
  
      results.flatMap = flatMap.shim(); // TODO: remove after upgrading to node v12
  
      // extract distinct tag ids
      const allTagIds = results
        .flatMap(result => result.tagIds); // map + flatten
      const distinctTagIds = Array.from(new Set(allTagIds));
  
      // retrieve tag documents
      const Tag = mongoose.model('Tag');
      const tagIdToNameMap = await Tag.getIdToNameMap(distinctTagIds);
  
      // convert to map
      const idToTagNamesMap = {};
      results.forEach((result) => {
        const tagNames = result.tagIds
          .map(tagId => tagIdToNameMap[tagId])
          .filter(tagName => tagName != null); // filter null object
  
        idToTagNamesMap[result._id] = tagNames;
      });
  
      return idToTagNamesMap;
    }
  
    static async updatePageTags(pageId, tags) {
      if (pageId == null || tags == null) {
        throw new Error('args \'pageId\' and \'tags\' are required.');
      }
  
      // filter empty string
      // eslint-disable-next-line no-param-reassign
      tags = tags.filter((tag) => { return tag !== '' });
  
      const Tag = mongoose.model('Tag');
  
      // get relations for this page
      const relations = await this.findByPageId(pageId, { nullable: true });
  
      const unlinkTagRelationIds = [];
      const relatedTagNames = [];
  
      relations.forEach((relation) => {
        if (relation.relatedTag == null) {
          unlinkTagRelationIds.push(relation._id);
        }
        else {
          relatedTagNames.push(relation.relatedTag.name);
          if (!tags.includes(relation.relatedTag.name)) {
            unlinkTagRelationIds.push(relation._id);
          }
        }
      });
      const bulkDeletePromise = this.deleteMany({ _id: { $in: unlinkTagRelationIds } });
      // find or create tags
      const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
      const tagEntities = await Tag.findOrCreateMany(tagsToCreate);
  
      // create relations
      const bulkCreatePromise = this.insertMany(
        tagEntities.map((relatedTag) => {
          return {
            relatedPage: pageId,
            relatedTag,
          };
        }),
      );
  
      return Promise.all([bulkDeletePromise, bulkCreatePromise]);
    }
  
  }
  
  module.exports = function() {
    schema.loadClass(PageTagRelation);
    const model = mongoose.model('PageTagRelation', schema);
    return model;
  };
  