const { SharedCounterDataLoader, singleJoinBy } = require('./util');
const DataLoader = require('dataloader');
const {
  SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS,
  SEARCH_OTHERS_COMMENTS,
} = require('../../perms/constants');
const {
  CACHE_EXPIRY_COMMENT_COUNT,
  ALLOW_NO_LIMIT_QUERIES,
} = require('../../config');
const ms = require('ms');
const sc = require('snake-case');

const CommentModel = require('../../models/comment');

/**
 * Returns the comment count for all comments that are public based on their
 * asset ids.
 * @param {Object}        context     graph context
 * @param {Array<String>} asset_ids   the ids of assets for which there are
 *                                    comments that we want to get
 */
const getCountsByAssetID = (context, asset_ids) => {
  return CommentModel.aggregate([
    {
      $match: {
        asset_id: {
          $in: asset_ids,
        },
        status: {
          $in: ['NONE', 'ACCEPTED'],
        },
      },
    },
    {
      $group: {
        _id: '$asset_id',
        count: {
          $sum: 1,
        },
      },
    },
  ])
    .then(singleJoinBy(asset_ids, '_id'))
    .then(results => results.map(result => (result ? result.count : 0)));
};

/**
 * Returns the comment count for all comments that are public based on their
 * asset ids.
 * @param {Object}        context     graph context
 * @param {Array<String>} asset_ids   the ids of assets for which there are
 *                                    comments that we want to get
 */
const getParentCountsByAssetID = (context, asset_ids) => {
  return CommentModel.aggregate([
    {
      $match: {
        asset_id: {
          $in: asset_ids,
        },
        status: {
          $in: ['NONE', 'ACCEPTED'],
        },
        parent_id: null,
      },
    },
    {
      $group: {
        _id: '$asset_id',
        count: {
          $sum: 1,
        },
      },
    },
  ])
    .then(singleJoinBy(asset_ids, '_id'))
    .then(results => results.map(result => (result ? result.count : 0)));
};

/**
 * Retrieves the count of comments based on the passed in query.
 * @param  {Object} ctx   graph context
 * @param  {Object} query     query to execute against the comments collection
 *                            to compute the counts
 * @return {Promise}          resolves to the counts of the comments from the
 *                            query
 */
const getCommentCountByQuery = (ctx, options) => {
  const {
    statuses,
    asset_id,
    parent_id,
    author_id,
    tags,
    action_type,
    excludeDeleted,
  } = options;

  // If user queries for statuses other than NONE and/or ACCEPTED statuses, it needs
  // special privileges.
  if (
    (!statuses ||
      statuses.some(status => !['NONE', 'ACCEPTED'].includes(status))) &&
    (ctx.user == null || !ctx.user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS))
  ) {
    return null;
  }

  const query = CommentModel.find();

  if (asset_id != null) {
    query.merge({ asset_id });
  }

  if (parent_id !== undefined) {
    query.merge({ parent_id });
  }

  if (author_id) {
    query.merge({ author_id });
  }

  if (excludeDeleted) {
    // The null query matches documents that either contain the `deleted_at`
    // field whose value is null or that do not contain the `deleted_at` field.
    query.merge({ deleted_at: null });
  }

  if (ctx.user != null && ctx.user.can(SEARCH_OTHERS_COMMENTS) && action_type) {
    query.merge({
      [`action_counts.${sc(action_type.toLowerCase())}`]: {
        $gt: 0,
      },
    });
  }

  if (statuses && statuses.length > 0) {
    query.merge({ status: { $in: statuses } });
  }

  if (tags && tags.length > 0) {
    query.merge({
      'tags.tag.name': {
        $in: tags,
      },
    });
  }

  return CommentModel.find(query).count();
};

/**
 * getStartCursor will retrieve the start cursor based on the sortBy field.
 *
 * @param {Object} ctx the graph context
 * @param {Object} nodes the result set of retrieved comments
 * @param {Object} params the params from the client describing the query
 */
const getStartCursor = (ctx, nodes, { cursor, sortBy }) => {
  if (sortBy === 'CREATED_AT') {
    return nodes.length ? nodes[0].created_at : null;
  } else if (sortBy === 'REPLIES') {
    // The cursor is the start! This is using numeric pagination.
    return cursor != null ? cursor : 0;
  }

  const SORT_KEY = sortBy.toLowerCase();
  if (
    !ctx.plugins ||
    !ctx.plugins.Sort.Comments ||
    !ctx.plugins.Sort.Comments[SORT_KEY] ||
    !ctx.plugins.Sort.Comments[SORT_KEY].startCursor
  ) {
    throw new Error(
      `unable to sort by ${sortBy}, no plugin was provided to handle this type`
    );
  }

  return ctx.plugins.Sort.Comments[SORT_KEY].startCursor(ctx, nodes, {
    cursor,
  });
};

/**
 * getEndCursor will fetch the end cursor based on the desired sortBy parameter.
 *
 * @param {Object} ctx the graph context
 * @param {Object} nodes the result set of retrieved comments
 * @param {Object} params the params from the client describing the query
 */
const getEndCursor = (ctx, nodes, { cursor, sortBy }) => {
  if (sortBy === 'CREATED_AT') {
    return nodes.length ? nodes[nodes.length - 1].created_at : null;
  } else if (sortBy === 'REPLIES') {
    return nodes.length ? (cursor != null ? cursor : 0) + nodes.length : null;
  }

  const SORT_KEY = sortBy.toLowerCase();
  if (
    !ctx.plugins ||
    !ctx.plugins.Sort.Comments ||
    !ctx.plugins.Sort.Comments[SORT_KEY] ||
    !ctx.plugins.Sort.Comments[SORT_KEY].endCursor
  ) {
    throw new Error(
      `unable to sort by ${sortBy}, no plugin was provided to handle this type`
    );
  }

  return ctx.plugins.Sort.Comments[SORT_KEY].endCursor(ctx, nodes, { cursor });
};

/**
 * applySort will add the actual `.sort` and `.skip/.where` clauses to the query
 * to apply the desired sort.
 *
 * @param {Object} ctx the graph context
 * @param {Object} query the current mongoose query object
 * @param {Object} params the params from the client describing the query
 */
const applySort = (ctx, query, { cursor, sortOrder, sortBy }) => {
  if (sortBy === 'CREATED_AT') {
    if (cursor) {
      if (sortOrder === 'DESC') {
        query = query.where({
          created_at: {
            $lt: cursor,
          },
        });
      } else {
        query = query.where({
          created_at: {
            $gt: cursor,
          },
        });
      }
    }

    return query.sort({ created_at: sortOrder === 'DESC' ? -1 : 1 });
  } else if (sortBy === 'REPLIES') {
    if (cursor) {
      query = query.skip(cursor);
    }

    return query.sort({
      reply_count: sortOrder === 'DESC' ? -1 : 1,
      created_at: sortOrder === 'DESC' ? -1 : 1,
    });
  }

  const SORT_KEY = sortBy.toLowerCase();
  if (
    !ctx.plugins ||
    !ctx.plugins.Sort.Comments ||
    !ctx.plugins.Sort.Comments[SORT_KEY] ||
    !ctx.plugins.Sort.Comments[SORT_KEY].sort
  ) {
    throw new Error(
      `unable to sort by ${sortBy}, no plugin was provided to handle this type`
    );
  }

  return ctx.plugins.Sort.Comments[SORT_KEY].sort(ctx, query, {
    cursor,
    sortOrder,
  });
};

/**
 * executeWithSort will actually retrieve the comments based on the pre-assembled
 * query and will compose on top the sort operators necessary to get the desired
 * result.
 *
 * @param {Object} ctx the graph context
 * @param {Object} query the current mongoose query object
 * @param {Object} params the params from the client describing the query
 */
const executeWithSort = async (
  ctx,
  query,
  { cursor, sortOrder, sortBy, limit }
) => {
  // Apply the sort to the query.
  query = applySort(ctx, query, { cursor, sortOrder, sortBy });

  // Apply the limit (if it exists, as it's applied universally).
  if (limit >= 0) {
    query = query.limit(limit + 1);
  }

  // Fetch the nodes based on the source query.
  const nodes = await query.exec();

  // The hasNextPage is always handled the same (ask for one more than we need,
  // if there is one more, than there is more).
  let hasNextPage = false;
  if (limit >= 0 && nodes.length > limit) {
    // There was one more than we expected! Set hasNextPage = true and remove
    // the last item from the array that we requested.
    hasNextPage = true;
    nodes.splice(limit, 1);
  }

  // Use the generator functions below to extract the cursor details based on
  // the current sortBy parameter.
  return {
    startCursor: getStartCursor(ctx, nodes, {
      cursor,
      sortBy,
    }),
    endCursor: getEndCursor(ctx, nodes, { cursor, sortBy }),
    hasNextPage,
    nodes,
  };
};

/**
 * Retrieves comments based on the passed in query that is filtered by the
 * current used passed in via the context.
 *
 * @param  {Object} context   graph context
 * @param  {Object} query     query terms to apply to the comments query
 */
const getCommentsByQuery = async (
  ctx,
  {
    ids,
    statuses,
    asset_id,
    parent_id,
    author_id,
    limit,
    cursor,
    sortOrder,
    sortBy,
    excludeIgnored,
    excludeDeleted,
    tags,
    action_type,
  }
) => {
  const query = CommentModel.find();

  // Enforce that the limit must be gte 0 if this option is not true.
  if (!ALLOW_NO_LIMIT_QUERIES && limit < 0) {
    throw new Error('cannot query for limit < 0');
  }

  // If user queries for statuses other than NONE and/or ACCEPTED statuses, it needs
  // special privileges.
  if (
    (!statuses ||
      statuses.some(status => !['NONE', 'ACCEPTED'].includes(status))) &&
    (ctx.user == null || !ctx.user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS))
  ) {
    return null;
  }

  if (statuses) {
    query.merge({ status: { $in: statuses } });
  }

  if (excludeDeleted) {
    // The null query matches documents that either contain the `deleted_at`
    // field whose value is null or that do not contain the `deleted_at` field.
    query.merge({ deleted_at: null });
  }

  if (ctx.user != null && ctx.user.can(SEARCH_OTHERS_COMMENTS) && action_type) {
    query.merge({
      [`action_counts.${sc(action_type.toLowerCase())}`]: {
        $gt: 0,
      },
    });
  }

  if (ids) {
    query.merge({
      id: {
        $in: ids,
      },
    });
  }

  if (tags) {
    query.merge({
      'tags.tag.name': {
        $in: tags,
      },
    });
  }

  // Only let an admin request any user or the current user request themself.
  if (
    ctx.user &&
    (ctx.user.can(SEARCH_OTHERS_COMMENTS) || ctx.user.id === author_id) &&
    author_id != null
  ) {
    query.merge({ author_id });
  }

  if (asset_id) {
    query.merge({ asset_id });
  }

  // We perform the undefined check because, null, is a valid state for the
  // search to be with, which indicates that it is at depth 0.
  if (parent_id !== undefined) {
    query.merge({ parent_id });
  }

  if (
    excludeIgnored &&
    ctx.user &&
    ctx.user.ignoresUsers &&
    ctx.user.ignoresUsers.length > 0
  ) {
    query.merge({
      author_id: { $nin: ctx.user.ignoresUsers },
    });
  }

  return executeWithSort(ctx, query, { cursor, sortOrder, sortBy, limit });
};

/**
 * getComments returns the comments by the id's. Only admins can see non-public
 * comments.
 *
 * @param  {Object}        context graph context
 * @param  {Array<String>} ids     the comment id's to fetch
 * @return {Promise}       resolves to the comments
 */
const getComments = ({ user }, ids) => {
  let comments;
  if (user && user.can(SEARCH_OTHERS_COMMENTS)) {
    comments = CommentModel.find({
      id: {
        $in: ids,
      },
    });
  } else {
    comments = CommentModel.find({
      id: {
        $in: ids,
      },
      status: {
        $in: ['NONE', 'ACCEPTED'],
      },
    });
  }
  return comments.then(singleJoinBy(ids, 'id'));
};

/**
 * Creates a set of loaders based on a GraphQL context.
 *
 * @param  {Object} context the context of the GraphQL request
 * @return {Object}         object of loaders
 */
module.exports = context => ({
  Comments: {
    get: new DataLoader(ids => getComments(context, ids)),
    getByQuery: query => getCommentsByQuery(context, query),
    getCountByQuery: query => getCommentCountByQuery(context, query),
    countByAssetID: new SharedCounterDataLoader(
      'Comments.totalCommentCount',
      ms(CACHE_EXPIRY_COMMENT_COUNT),
      ids => getCountsByAssetID(context, ids)
    ),
    parentCountByAssetID: new SharedCounterDataLoader(
      'Comments.countByAssetID',
      ms(CACHE_EXPIRY_COMMENT_COUNT),
      ids => getParentCountsByAssetID(context, ids)
    ),
  },
});
