import { ProjectionFieldSet } from 'projection-utils';
import _ from 'underscore';

/**
 * Produce a ProjectionFieldSet from the given MongoDB projection, after validating it to ensure it
 * doesn't have exclusion rules.
 *
 * @param projection - The projected fields.
 * @param includeIdDefault - Whether to include _id by default (MongoDB's default behavior).
 * @returns The synthesized field set.
 */
function fieldsFromMongo(
  projection: Record<string, any> = {},
  includeIdDefault = false
): ProjectionFieldSet {
  const fields = _.reduce(
    projection,
    (memo, value, key) => {
      if (key !== '_id' && value !== undefined && !value) {
        throw new TypeError('projection includes exclusion, but we do not support that');
      }
      if (value || (key === '_id' && value === undefined && includeIdDefault)) {
        memo.push(key);
      }

      return memo;
    },
    []
  );

  return ProjectionFieldSet.fromDotted(fields);
}

/**
 * Resolve the fields object, given potentially untrusted fields the user has provided, permitted
 * fields defined by the application, and override fields that should always be provided.
 *
 * @param desiredFields - The fields in the request.
 * @param allowedFields - A shallow fields object defining the fields permitted in desiredFields.
 *                         If not provided, any field is allowed.
 * @param overrideFields - A shallow fields object defining fields that should always be included
 *                         or excluded as specified.
 * @returns The resolved fields declaration, or null if no fields are valid.
 */
function resolveFields(
  desiredFields?: string[] | null,
  allowedFields?: Record<string, any> | null,
  overrideFields?: Record<string, any> | null
): Record<string, any> | null {
  if (desiredFields != null && !Array.isArray(desiredFields)) {
    throw new TypeError('expected nullable array for desiredFields');
  }

  if (allowedFields != null && !_.isObject(allowedFields)) {
    throw new TypeError('expected nullable plain object for allowedFields');
  }

  if (overrideFields !== undefined && !_.isObject(overrideFields)) {
    throw new TypeError('expected optional plain object for overrideFields');
  }

  // If no desired fields are specified, we treat that as wanting the default set of fields.
  const desiredFieldset = _.isEmpty(desiredFields)
    ? new ProjectionFieldSet([[]])
    : ProjectionFieldSet.fromDotted(desiredFields);

  // If allowedFields isn't provided, we treat that as not having restrictions. However, if it's an
  // empty array, we treat that as have no valid fields.
  const allowedFieldset = allowedFields
    ? fieldsFromMongo(allowedFields)
    : new ProjectionFieldSet([[]]);

  // Don't trust fields passed in the querystring, so whitelist them against the
  // fields defined in parameters. Add override fields from parameters.
  const fields = desiredFieldset.intersect(allowedFieldset).union(fieldsFromMongo(overrideFields));

  if (fields.isEmpty()) {
    // This projection isn't representable as a mongo projection - nor should it be. We don't want
    // to query mongo for zero fields.
    return null;
  }

  // Generate the mongo projection.
  const projection = fields.toMongo();

  // Whether overrideFields explicitly removes _id.
  const disableIdOverride =
    overrideFields && overrideFields._id !== undefined && !overrideFields._id;

  // Explicitly exclude the _id field (which mongo includes by default) if we don't allow it, or
  // if we've disabled it in the override.
  if (!fields.contains(['_id']) || disableIdOverride) {
    // If the override excludes _id, then enforce that here. All other fields will be included by
    // default, so we don't need to specify them individually, as we only support whitelisting
    // fields, and do not support field blacklists.
    projection._id = 0;
  }
  return projection;
}

export default resolveFields;
