Source

index.ts

import { nonenumerable } from "nonenumerable";

type CollectionNamespace = { collection: string };
type CollectionInfo = { namespace: CollectionNamespace };
type Collection = { s: CollectionInfo };
type MixedCollectionName = string | Collection;

type Binary<N extends number = number> = string & {
  readonly BinaryStringLength: unique symbol;
  length: N;
};

type ObjectExpression = { [k: string]: any } | string;

/**
 * A valid expression, string, number, boolean or null.
 */
type Expression = ObjectExpression | number | boolean;

/**
 * A number or any valid expression that resolves to a number.
 */
type NumberExpression = ObjectExpression | number | string;

/**
 * An array or any valid expression that resolves to an array.
 */
type ArrayExpression = ObjectExpression | Array<any> | string;

/**
 * A string or any valid expression that resolves to a string.
 */
type StringExpression = ObjectExpression | Array<any> | string;

/**
 * A date or any valid expression that resolves to a date.
 */
type DateExpression = ObjectExpression | Date | string;

type ArrayOfExpressions = Array<ObjectExpression>;

type Timestamp = number;

// always two
const at = (ns: string) => (...args: any[]) => {
  if (args.length !== 2) {
    throw new TypeError(`${ns} expects two arguments. Received ${args.length} arguments.`);
  }
  const [a1, a2] = args;
  return { [ns]: [a1, a2] };
};

// pass thru args (as array)
const pta = (ns: string) => (...args: any[]) => ({ [ns]: args });
// pass thru args (or first arg as array)
const ptafaa = (ns: string) => (...args: any[]) => ({ [ns]: args.length === 1 && Array.isArray(args) ? args[0] : args });
// no expression
const ne = (ns: string) => () => ({ [ns]: {} });
// single expression
const se = (ns: string, validate?: (ns: string, expr: Expression) => void) => (expr: Expression) => {
  if (validate) validate(ns, expr);
  return { [ns]: expr };
};

// [dynamic] two or one arguments indicate varied application in drive
const taf = (ns: string) => (...args: any[]) => {
  if (args.length > 1) {
    return { [ns]: args };
  }
  return { [ns]: args[0] };
};

const onlyOnce = (subject: { [k: string]: any }, methodName: string) => {
  const subjectText = subject.constructor.name;
  if (subject[methodName] === undefined) {
    throw new Error(`Method name "${methodName}" does not exist on ${subjectText}`);
  }
  if (typeof subject[methodName] !== 'function') {
    throw new Error(`The ${methodName} method is not a function on ${subjectText}`);
  }
  Object.defineProperty(subject, methodName, {
    value: function() {
      throw new Error(`Redundant call to ${methodName}() on ${subjectText} not allowed.`);
    },
  });
};

const validateFieldExpression = (ns: string, v: any) => {
  const type = typeof v;
  if (type === 'object') {
    if (Object.getPrototypeOf(v) !== Object.getPrototypeOf({})) {
      const name = v.constructor.name || 'unknown';
      throw new Error(`Expression for ${ns} expected to be a plain object. Received ${name}`);
    }
    if (Object.keys(v).length <= 0) {
      throw new Error(`Expression for ${ns} cannot be an empty object`);
    }
  } else {
    throw new TypeError(`Expression for ${ns} expected to be an object. Received: ${type}`);
  }
};

const getCollectionName = (v: MixedCollectionName) => {
  if (typeof v === 'string') {
    return v;
  }
  if (v.s && v.s.namespace && typeof v.s.namespace.collection === 'string') {
    return v.s.namespace.collection;
  }
  throw new TypeError(`Invalid $lookup from parameter: ${v}`);
};


type OperatorFn = (...args: any[]) => ObjectExpression;

const safeNumberArgs = (fn: OperatorFn) => (...args: any[]) => {
  const nums = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
  return fn(...nums.map((arg) => {
    switch (typeof arg) {
      case 'boolean':
        return arg ? 1 : 0;
      case 'number':
        return arg;
      case 'string':
        if (arg.match(/^[^$]/)) return 0;
        break;
      case 'object':
        if (arg === null) return 0;
        break;
      case 'undefined':
        return 0;
      default:
    }
    return $ifNull(arg, 0);
  }));
};

type PipelineStage = Expression;

// Allow easier syntax for conditional stages in pipeline.
const pipeline = (...args: PipelineStage[]) => args.filter((v) => v).map((v) => {
  return typeof v === 'function' ? v(v) : v;
});

/**
 * STAGES
 */

type AddFieldsStage = { $addFields: ObjectExpression };

/**
 * Adds new fields to documents.
 * @category Stages
 * @function
 * @param {ObjectExpression} expression Specify the name of each field to add
 * and set its value to an aggregation expression or an empty object.
 * @returns {AddFieldsStage} 
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/addFields/|MongoDB reference}
 * for $addFields
 * @example
 * $addFields({ fullName: $.concat('$firstName', ' ', '$lastName') });
 * // returns
 * { $addFields: { fullName: { $concat: ['$firstName', ' ', '$lastName'] } } }
 */
const $addFields = se('$addFields', validateFieldExpression);


type BaseBucketExpression = {
  groupBy: Expression,
  output: ObjectExpression,
};

type BucketExpression = BaseBucketExpression & {
  boundaries: Array<number>,
  default: Expression,
};

class Bucket {
  public $bucket: Partial<BucketExpression> = {};

  constructor(
    groupBy: Expression,
    boundaries?: Array<number>,
    defaultId?: Expression,
    output?: ObjectExpression,
  ) {
    this.groupBy(groupBy);
    if (boundaries) {
      this.boundaries(...boundaries);
    }
    if (defaultId) {
      this.default(defaultId);
    }
    if (output) {
      this.output(output);
    }
  }

  groupBy(value: Expression) {
    this.$bucket.groupBy = value;
    onlyOnce(this, 'groupBy');
    return this;
  }

  output(document: ObjectExpression) {
    this.$bucket.output = document;
    onlyOnce(this, 'output');
    return this;
  }

  default(value: Expression) {
    this.$bucket.default = value;
    onlyOnce(this, 'default');
    return this;
  }

  boundaries(...args: Array<number>) {
    this.$bucket.boundaries = args;
    onlyOnce(this, 'boundaries');
    return this;
  }
}

/**
 * Categorizes incoming documents into groups called buckets, based on a 
 * specified expression and bucket boundaries.
 * @category Stages
 * @function
 * @param {Expression} groupBy An expression to group documents by.
 * @param {Array<number>} [boundaries] An array of values based on the groupBy
 * expression that specify the boundaries for each bucket.
 * @param {Expression} [defaultId] Optional. A literal that specifies the _id of
 * an additional bucket that contains all documents that don't fall into a 
 * bucket specified by boundaries.
 * @param {ObjectExpression} output Optional. A document that specifies the
 * fields to include.
 * @returns {Bucket} A Bucket object populated according to argument input.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/bucket/|MongoDB reference}
 * for $bucket
 * @example <caption>Static notation</caption>
 * $bucket('$price', [0, 200, 400], 'Other');
 * // outputs
 * { $bucket: { groupBy: '$price', boundaries: [0, 200, 400], default: 'Other' } }
 * @example <caption>Object notation</caption>
 * $bucket('$price').boundaries(0, 200, 400).default('Other');
 * // outputs
 * { $bucket: { groupBy: '$price', boundaries: [0, 200, 400], default: 'Other' } }
 */
const $bucket = (
  groupBy: Expression,
  boundaries?: Array<number>,
  defaultId?: Expression,
  output?: ObjectExpression,
) => new Bucket(groupBy, boundaries, defaultId, output);

type BucketAutoExpression = BaseBucketExpression & {
  buckets: number,
  granularity: string,
};

class BucketAuto {
  public $bucketAuto: Partial<BucketAutoExpression> = {};

  constructor(
    groupBy: Expression,
    buckets?: number,
    granularity?: string,
    output?: ObjectExpression,
  ) {
    this.groupBy(groupBy);
    if (buckets) {
      this.buckets(buckets);
    }
    if (granularity) {
      this.granularity(granularity);
    }
    if (output) {
      this.output(output);
    }
  }

  groupBy(value: Expression) {
    this.$bucketAuto.groupBy = value;
    onlyOnce(this, 'groupBy');
    return this;
  }

  output(document: ObjectExpression) {
    this.$bucketAuto.output = document;
    onlyOnce(this, 'output');
    return this;
  }

  buckets(value: number) {
    this.$bucketAuto.buckets = value;
    onlyOnce(this, 'buckets');
    return this;
  }

  granularity(value: string) {
    this.$bucketAuto.granularity = value;
    onlyOnce(this, 'granularity');
    return this;
  }
}

/**
 * Categorizes incoming documents into a specific number of groups, called
 * buckets, based on a specified expression.
 * @category Stages
 * @function
 * @param {Expression} groupBy An expression to group documents by.
 * @param {number} [buckets] A positive number for the number of buckets into
 * which input documents will be grouped.
 * expression that specify the boundaries for each bucket.
 * @param {string} [granularity] Optional. Specifies the preferred number series
 * to use.
 * @param {ObjectExpression} [output] Optional. A document that specifies the
 * fields to include.
 * @returns {Bucket} A Bucket object populated according to argument input.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/bucketAuto/|MongoDB reference}
 * for $bucketAuto
 * @example <caption>Static notation</caption>
 * $bucketAuto('$_id', 5);
 * // returns
 * { $bucketAuto: { groupBy: '$_id', buckets: 5 } }
 * @example <caption>Object notation</caption>
 * $bucketAuto('$_id').buckets(5).granularity('R5');
 * // returns
 * { $bucketAuto: { groupBy: '$_id', buckets: 5, granularity: 'R5' } }
 */
const $bucketAuto = (
  groupBy: Expression, 
  buckets?: number,
  granularity?: string,
  output?: ObjectExpression,
) => new BucketAuto(groupBy, buckets, granularity, output);

enum ChangeStreamFullDocument {
  default,
  required,
  updateLookup,
  whenAvailable,
}

enum ChangeStreamFullDocumentBeforeChange {
  off,
  whenAvailable,
  required,
}

type ChangeStreamExpression = {
  allChangesForCluster?: boolean,
  fullDocument?: ChangeStreamFullDocument,
  fullDocumentBeforeChange?: ChangeStreamFullDocumentBeforeChange,
  resumeAfter?: number,
  showExpandedEvents?: boolean,
  startAfter?: ObjectExpression,
  startAtOperationTime?: Timestamp,
};

class ChangeStream {
  public $changeStream: ChangeStreamExpression = {};

  constructor(opts: ChangeStreamExpression = {}) {
    if (opts.allChangesForCluster) this.allChangesForCluster(opts.allChangesForCluster);
    if (opts.fullDocument) this.fullDocument(opts.fullDocument);
    if (opts.fullDocumentBeforeChange) this.fullDocumentBeforeChange(opts.fullDocumentBeforeChange);
    if (opts.resumeAfter) this.resumeAfter(opts.resumeAfter);
    if (opts.showExpandedEvents) this.showExpandedEvents(opts.showExpandedEvents);
    if (opts.startAfter) this.startAfter(opts.startAfter);
    if (opts.startAtOperationTime) this.startAtOperationTime(opts.startAtOperationTime);
    // Object.entries(opts).forEach(([opt, val]) => this[opt as keyof ChangeStreamExpression](val));
    // Object.entries(opts).forEach(([opt, val]) => this[opt as keyof ChangeStreamExpression](val));
  }

  allChangesForCluster(value: boolean) {
    this.$changeStream.allChangesForCluster = value;
    onlyOnce(this, 'allChangesForCluster');
    return this;
  }

  fullDocument(value: ChangeStreamFullDocument) {
    this.$changeStream.fullDocument = value;
    onlyOnce(this, 'fullDocument');
    return this;
  }

  fullDocumentBeforeChange(value: ChangeStreamFullDocumentBeforeChange) {
    this.$changeStream.fullDocumentBeforeChange = value;
    onlyOnce(this, 'fullDocument');
    return this;
  }

  resumeAfter(value: number) {
    this.$changeStream.resumeAfter = value;
    onlyOnce(this, 'resumeAfter');
    return this;
  }

  showExpandedEvents(value: boolean) {
    this.$changeStream.showExpandedEvents = value;
    onlyOnce(this, 'showExpandedEvents');
    return this;
  }

  startAfter(value: ObjectExpression) {
    this.$changeStream.startAfter = value;
    onlyOnce(this, 'startAfter');
    return this;
  }

  startAtOperationTime(value: Timestamp) {
    this.$changeStream.startAtOperationTime = value;
    onlyOnce(this, 'startAtOperationTime');
    return this;
  }
}

/**
 * Returns a Change Stream cursor on a collection, a database, or an entire
 * cluster. Must be the first stage in the pipeline.
 * @category Stages
 * @function
 * @param {ChangeStreamExpression} [opts] Change stream options.
 * @returns {ChangeStream} A ChangeStream instance populated according to
 * argument input.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/changeStream/|MongoDB reference}
 * for $changeStream
 * @example <caption>Basic example</caption>
 * $changeStream();
 * // returns
 * { $changeStream: {} }
 */
const $changeStream = (opts?: ChangeStreamExpression) => new ChangeStream(opts)

type CountOperator = {
  $count: string,
};

/**
 * Passes a document to the next stage that contains a count of the number of
 * documents input to the stage.
 * @category Stages
 * @function
 * @param {string} [name] The name of the output field for the count value.
 * Must be a non-empty string, must not start with `$` nor contain `.`.
 * @returns {CountOperator} A $count operator expression.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/addFields/|MongoDB reference}
 * for $addFields
 * @example
 * $count('myCount');
 * // returns
 * { $count: 'myCount' }
 * @example <caption>Use default name "count"</caption>
 * $count();
 * // returns
 * { $count: "count" }
 */
const $count = (name = 'count') => ({ $count: name });

type DocumentsStage = {
  $documents: ObjectExpression;
};

/**
 * Returns literal documents from input values.
 * @category Stages
 * @function
 * @param {ObjectExpression | Array<ObjectExpression>} mixed The first document
 * or an array of documents.
 * @param {...ObjectExpression[]} [args] Additional documents to input into the
 * pipeline.
 * @returns {DocumentsStage} Returns a $document operator based on input.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/documents/|MongoDB reference}
 * for $documents
 * @example
 * $documents({ x: 10 }, { x: 2 }, { x: 5 });
 * // returns
 * { $documents: [{ x: 10 }, { x: 2 }, { x: 5 }
 */
const $documents = (mixed: ObjectExpression | Array<ObjectExpression>, ...args: ObjectExpression[]) => {
  let documents;
  if (Array.isArray(mixed)) {
    documents = mixed as Array<ObjectExpression>;
  } else {
    documents = args.length ? [mixed, ...args] : [mixed];
  }
  return { $documents: documents };
};

type FacetExpression = { [k: string]: Array<PipelineStage> };
type FacetStage = { $facet: FacetExpression };

/**
 * Processes multiple aggregation pipelines within a single stage on the same
 * set of input documents.
 * @category Stages
 * @function
 * @param {FacetExpression} expression Refer to documentation.
 * @returns {FacetStage}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/project/|MongoDB reference}
 * for $project
 * @example
 * $project({ x: '$y' });
 * // returns
 * { $project: { x: '$y' } }
 */
const $facet = se('$facet');

type GroupStage = {
  $group: ObjectExpression,
};

/**
 * Separates documents into groups according to a "group key".
 * @category Stages
 * @function
 * @param {ObjectExpression} expression Refer to documentation.
 * @returns {GroupStage}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/group/|MongoDB reference}
 * for $group
 * @example
 * $group({ _id: '$userId', total: $sum(1)) });
 * // returns
 * { $group: { _id: '$userId', total: { $sum: 1 } } };
 */
const $group = se('$group');

type LimitStage = {
  $limit: number,
};

/**
 * Limits the number of documents passed to the next stage in the pipeline.
 * @category Stages
 * @function
 * @param {number} value A positive 64bit integer.
 * @returns {LimitStage}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/limit/|MongoDB reference}
 * for $limit
 * @example
 * $limit(10);
 * // returns
 * { $limit: 10 }
 */
const $limit = se('$limit');

type LookupExpression = {
  from: string;
  as: string;
  localField?: string;
  foreignField?: string;
  let?: Expression;
  pipeline?: PipelineStage[];
};

class Lookup {
  public $lookup: Partial<LookupExpression> = {};

  public constructor(
    from: MixedCollectionName | Array<ObjectExpression>,
    as: string,
    localField?: string,
    foreignField?: string
  ) {
    if (Array.isArray(from)) {
      this.$lookup.pipeline = [$documents(...from as [ObjectExpression, ...ObjectExpression[]])];
    } else {
      this.from(from);
    }

    this.as(as);
    if (localField) this.localField(localField);
    if (foreignField) this.foreignField(foreignField);
  }

  from(v: MixedCollectionName) {
    this.$lookup.from = getCollectionName(v);
    onlyOnce(this, 'from');
    return this;
  }

  as(v: string) {
    this.$lookup.as = v;
    onlyOnce(this, 'as');
    return this;
  }

  localField(v: string) {
    this.$lookup.localField = v;
    onlyOnce(this, 'localField');
    return this;
  }

  foreignField(v: string) {
    this.$lookup.foreignField = v;
    onlyOnce(this, 'foreignField');
    return this;
  }

  let(v: Expression) {
    this.$lookup.let = v;
    onlyOnce(this, 'let');
    return this;
  }

  pipeline(...args: PipelineStage[]) {
    let stages;
    if (args.length === 1 && Array.isArray(args[0])) {
      [stages] = args;
    } else {
      stages = args;
    }
    if (this.$lookup.pipeline === undefined) {
      this.$lookup.pipeline = stages as PipelineStage[];
    } else {
      this.$lookup.pipeline.push(...stages as PipelineStage[]);
    }
    onlyOnce(this, 'pipeline');
    return this;
  }
}

/**
 * Performs a left outer join to a collection in the same database adding a new
 * array field to each input document.
 * @category Stages
 * @function
 * @param {MixedCollectionName | Array<ObjectExpression>} from Specifies the collection in the same
 * database to perform the join with. Can be either the collection name or the
 * Collection object.
 * @param {string} [asName] Specifies the name of the new array field to add to
 * the input documents where the results of the lookup will be.
 * @param {string} [localField] Specifies the field from the documents input to 
 * the lookup stage.
 * @param {string} [foreignField] Specifies the field in the from collection the documents input to 
 * the lookup stage.
 * @returns {Lookup} A Lookup instance populated based on argument input that
 * contains the relevant methods for otherwise configuring a $lookup stage.
 * @example <caption>Common lookup</caption>
 * $lookup('myCollection', 'myVar', 'localId', 'foreignId');
 * // returns a Lookup object populated like:
 * { $lookup: { from: 'myCollection', as: 'myVar', localField: 'localId', foreignField: 'foreignId' } }
 * @example <caption>Lookup with $documents in pipeline</caption>
 * // Pass the documents as an array using the "from" argument
 * $lookup([{ k: 1 }, { k: 2 }], 'subdocs')
 *   .let({ key: '$docKey' })
 *   .pipeline($.match({ k: '$$key' }));
 * // returns a Lookup object populated as follows:
 * {
 *   $lookup: {
 *     as: 'subdocs',
 *     let: { localField: '$docKey' },
 *     pipeline: [
 *       { $documents: [{ k: 1 }, { k: 2 }] },
 *       { $match: { k: '$$localField' } },
 *     ],
 *   },
 * }
 */
const $lookup = (
  from: MixedCollectionName | Array<ObjectExpression>,
  asName: string,
  localField?: string,
  foreignField?: string,
) => new Lookup(from, asName, localField, foreignField);

type MatchStage = {
  $match: ObjectExpression,
};

/**
 * Filters the documents to pass only the documents that match the specified
 * conditions(s) to the next pipeline stage.
 * @category Stages
 * @function
 * @param {ObjectExpression} fieldExpression
 * @returns {MatchStage}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/match/|MongoDB reference}
 * for $match
 * @example
 * $match({ x: 1 });
 * // returns 
 * { $match: { x: 1 } },
 */
const $match = se('$match');

type DatabaseAndCollectionName = {
  db: string,
  coll: MixedCollectionName,
};

/**
 * @typedef {string} MergeActionWhenMatched
 * @description Enumeration of: Replace (replace), KeepExisting (keepExisting), Merge
 * (merge), Fail (fail) and Pipeline (pipeline).
 */
enum MergeActionWhenMatched {
  Replace = 'replace',
  KeepExisting = 'keepExisting',
  Merge = 'merge',
  Fail = 'fail',
  Pipeline = 'pipeline',
}

/**
 * @typedef {string} MergeActionWhenNotMatched
 * @description Enumeration of: Insert (insert), Discard (discard) or Fail
 * (fail).
 */
enum MergeActionWhenNotMatched {
  Insert = 'insert',
  Discard = 'discard',
  Fail = 'fail',
}

type MergeExpression = {
  into: string | DatabaseAndCollectionName,
  let?: Expression,
  on?: Expression,
  whenMatched?: MergeActionWhenMatched,
  whenNotMatched?: MergeActionWhenNotMatched,
};

class Merge {
  $merge: MergeExpression;

  constructor(into: MixedCollectionName, onExpr?: Expression) {
    this.$merge = { into: getCollectionName(into) };
    if (onExpr) this.on(onExpr);
  }

  on(onExpression: Expression) {
    this.$merge.on = onExpression;
    onlyOnce(this, 'on');
    return this;
  }

  let(varsExpression: Expression) {
    this.$merge.let = varsExpression;
    onlyOnce(this, 'let');
    return this;
  }

  whenMatched(action: MergeActionWhenMatched) {
    this.$merge.whenMatched = action;
    onlyOnce(this, 'whenMatched');
    return this;
  }

  whenNotMatched(action: MergeActionWhenNotMatched) {
    this.$merge.whenNotMatched = action;
    onlyOnce(this, 'whenNotMatched');
    return this;
  }
}

/**
 * Writes the results of the pipeline to a specified location. Must be the last
 * stage in the pipeline.
 * @category Stages
 * @function
 * @param {MixedCollectionName | DatabaseAndCollectionName} into The collectionName or Collection object to
 * merge into.
 * @param {string|Array<string>} [onExpr] Field or fields that act as a unique
 * identifier for the document.
 * @returns {Merge} Returns a Merge object populated according to the provided
 * arguments.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/project/|MongoDB reference}
 * for $project
 * @example <caption>On single-field</caption>
 * $merge('myCollection', 'key');
 * // returns
 * { $merge: { into: 'myCollection', on: 'key' } }
 * @example <caption>On multiple fields</caption>
 * $merge('myCollection', ['key1', 'key2']);
 * { $merge: { into: 'myCollection', on: ['key1', 'key2'] } }
 * @example <caption>Full example</caption>
 * $merge('myCollection', ['key1', 'key2'])
 *   .whenMatched('replace')
 *   .whenNotMatched('discard');
 * @todo Add support for accepting a pipleine for whenMatched.
 */
const $merge = (into: MixedCollectionName, onExpr?: string | Array<string>) => new Merge(into, onExpr);

type OutExpression =  string | DatabaseAndCollectionName;

/**
 * @category Stages
 * @param {MixedCollectionName} collection The collection name or Collection object
 * to output to.
 * @param {string} [db] The output database name.
 * @returns {OutExpression} An $out expression accoring to argument input.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/out/|MongoDB reference}
 * for $out
 * @example <caption>Basic</caption>
 * $out('myCollection');
 * // returns
 * { $out: 'myCollection' }
 * @example <caption>With db</caption>
 * $out('myCollection', 'myDb');
 * // returns
 * { $out: { coll: 'myCollection', db: 'myDb' } }
 */
const $out = (collection: MixedCollectionName, db?: string) => {
  if (db) return { $out: { db, coll: getCollectionName(collection) } };
  return { $out: collection };
};

type ProjectStage = {
  $project: ObjectExpression,
};

/**
 * Passes along the documents with the specified fields to the next stage in
 * the pipeline.
 * @category Stages
 * @function
 * @param {ObjectExpression} expression Refer to documentation.
 * @returns {ProjectStage}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/project/|MongoDB reference}
 * for $project
 * @example
 * $project({ x: '$y' });
 * // returns
 * { $project: { x: '$y' } }
 */
const $project = se('$project');

class Redaction {
  $redact: Condition;

  constructor(ifExpr: Expression, thenExpr?: Expression, elseExpr?: Expression) {
    this.$redact = new Condition(ifExpr, thenExpr, elseExpr);
  }

  then(thenExpr: Expression) {
    this.$redact.then(thenExpr);
    return this;
  }

  else(elseExpr: Expression) {
    this.$redact.else(elseExpr);
    return this;
  }
}

/**
 * Restricts entire documents or content within documents from being outputted
 * based on information stored in the documents themselves.
 * @category Stages
 * @function
 * @param {Expression} ifExpr Any valid expression as long as it resolves to
 * a boolean.
 * @param {Expression} thenExpr Any valid expression as long as it resolves to
 * the $$DESCEND, $$PRUNE, or $$KEEP system variables.
 * @param {Expression} elseExpr Any valid expression as long as it resolves to
 * the $$DESCEND, $$PRUNE, or $$KEEP system variables.
 * @returns {Redaction} Returns a Redaction object that resembles the $redact
 * stage whose usage varies based on optional argument input.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/redact/|MongoDB reference}
 * for $redact
 * @example <caption>Static Notation</caption>
 * $redact('$isAdmin', '$$KEEP', '$$PRUNE');
 * @example <caption>Object Notation</caption>
 * $redact('$isAdmin').then('$$KEEP').else('$$PRUNE');
 * @todo Expand for supporting sub-syntax like: `$redact().cond(...`
 * @todo Support non-$cond expression
 */
const $redact = (ifExpr: Expression, thenExpr?: Expression, elseExpr?: Expression) => new Redaction(ifExpr, thenExpr, elseExpr);

type ReplaceRootStage = {
  $replaceRoot: {
    newRoot: string | ObjectExpression,
  },
};

/**
 * Replaces the input document with the specified document.
 * @category Stages
 * @function
 * @param {string | ObjectExpression} newRoot The replacement document can be any valid express
 * @returns {ReplaceRootStage} Returns a $replaceRoot operator stage.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/replaceRoot/|MongoDB reference}
 * for $replaceRoot
 * @example
 * $replaceRoot('$subpath');
 * // returns
 * { $replaceRoot: { newRoot: '$subpath' } }
 */
const $replaceRoot = (newRoot: string | ObjectExpression) => ({ $replaceRoot: { newRoot } });

type ReplaceWithStage = { $replaceWith: ObjectExpression };

/**
 * Replaces the input document with the specified document.
 * @category Stages
 * @function
 * @param {string | ObjectExpression} replacementDocument The replacement
 * document can be any valid express
 * @returns {ReplaceWithStage} Returns a $replaceWith operator stage.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/replaceWith/|MongoDB reference}
 * for $replaceWith
 * @example
 * $replaceWith('$subpath');
 * // returns
 * { $replaceWith: '$subpath' }
 */
const $replaceWith = (replacementDocument: string | ObjectExpression) => ({ $replaceWith: replacementDocument });

type SetStage = { $set: ObjectExpression };

/**
 * Adds new fields to documents. (alias of $addFields)
 * @category Stages
 * @function
 * @param {ObjectExpression} expression Specify the name of each field to add
 * and set its value to an aggregation expression or an empty object.
 * @returns {SetStage} 
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/set/|MongoDB reference}
 * for $set
 * @example
 * $set({ fullName: $.concat('$firstName', ' ', '$lastName') });
 * // returns { $set: { fullName: { $concat: ['$firstName', ' ', '$lastName'] } } }
 */
const $set = se('$set', validateFieldExpression);

type SkipStage = {
  $skip: number,
};

/**
 * Skips over the specified number of documents that pass into the stage and
 * passes the remaining documents to the next stage in the pipeline.
 * @category Stages
 * @function
 * @param {number} value The number of documents to skip.
 * @returns {SkipStage}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/skip/|MongoDB reference}
 * for $skip
 * @example
 * $skip(10);
 * // returns
 * { $skip: 10 }
 */
const $skip = se('$skip');

type SortStage = {
  $sort: ObjectExpression,
};

/**
 * Sorts all input documents and returns them to the pipeline in sorted order.
 * @category Stages
 * @function
 * @param {Expression} expression Refer to documentation.
 * @returns {SortStage}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/sort/|MongoDB reference}
 * for $sort
 * @example
 * $sort({ x: 1 });
 * // returns
 * { $sort: { x: 1 } }
 */
const $sort = se('$sort');

type SortByCountStage = {
  $sortByCount: ObjectExpression,
};

/**
 * Groups incoming documents based on the value of a specified expression, then
 * computes the count of documents in each distinct group.
 * @category Stages
 * @function
 * @param {Expression} expression Refer to documentation.
 * @returns {SortByCountStage}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/sortByCount/|MongoDB reference}
 * for $sortByCount
 * @example
 * $sortByCount('$employee');
 * // returns
 * { $sortByCount: '$employee' }
 */
const $sortByCount = se('$sortByCount');

type UnwindExpression = {
  path: string;
  preserveNullAndEmptyArrays?: boolean;
  includeArrayIndex?: string;
};

class Unwind {
  @nonenumerable
  params: UnwindExpression;

  get path() {
    return this.params.path;
  }

  constructor(path: string, arg2: boolean | undefined) {
    this.params = { path };
    if (arg2 !== undefined) {
      this.preserveNullAndEmptyArrays(arg2);
    }
    Object.defineProperty(this, '$unwind', {
      get: () => {
        if (this.params.preserveNullAndEmptyArrays !== undefined || this.params.includeArrayIndex !== undefined) {
          return this.params;
        }
        return this.path;
      },
      enumerable: true,
    });
  }

  preserveNullAndEmptyArrays(value: boolean) {
    this.params.preserveNullAndEmptyArrays = value;
    onlyOnce(this, 'preserveNullAndEmptyArrays');
    return this;
  }

  includeArrayIndex(value: string) {
    this.params.includeArrayIndex = value;
    onlyOnce(this, 'includeArrayIndex');
    return this;
  }
}

/**
 * Deconstructs an array field from the input documents to output a document
 * for each element.
 * @category Stages
 * @function
 * @param {string} path Field path to an array field.
 * @param {boolean | undefined} [preserveNullAndEmptyArrays] Keep or prune documents that
 * don't have at least one value in the array field.
 * @returns {Unwind} Returns an Unwind object that resembles the $unwind stage
 * which can be further manipulated using the relevant methods.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/unwind/|MongoDB reference}
 * for $unwind
 * @example <caption>Static Notation</caption>
 * $unwind('$myArray');
 * // returns { $unwind: '$myArray' }
 * @example <caption>Static Notation and preserveNullAndEmptyArrays</caption>
 * $unwind('$myArray', true);
 * // returns { $unwind: { path: '$myArray', preserverNullAndEmptyArray: true } }
 * @example <caption>Include Array Index</caption>
 * $unwind('$myArray', true).includeArrayIndex('idxName');
 * // returns { $unwind: { path: '$myArray', preserverNullAndEmptyArray: true, includeArrayIndex: 'idxName' } }
 */
const $unwind = (path: string, preserveNullAndEmptyArrays: boolean | undefined = undefined) => new Unwind(path, preserveNullAndEmptyArrays);

/**
 * OPERATORS
 */

type AbsOperator = {
  $abs: NumberExpression,
}

/**
 * Returns the absolute value of a number.
 * @category Operators
 * @function
 * @param {NumberExpression} numberOrExpression A number or an expresison that
 * resolves to a number.
 * @returns {AbsOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/abs/|MongoDB reference}
 * for $abs
 * @example
 * $abs(-1);
 * // returns
 * { $abs: -1 }
 */
const $abs = se('$abs');

// TODO $accumulator (object notation required)

type AcosOperator = {
  $acos: NumberExpression,
};

/**
 * Returns the inverse cosine (arc cosine) of a value.
 * @category Operators
 * @function
 * @param {NumberExpression} numberOrExpression A number or an expression that
 * resolves to a number.
 * @returns {AcosOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/acos/|MongoDB reference}
 * for $acos
 * @example
 * $radiansToDegrees($acos($divide('$side_b', '$hypotenuse')));
 * // returns
 * { $radiansToDegrees: { $acos: { $divide: ['$side_b', '$hypotenuse'] } } }
 */
const $acos = se('$acos');

type AcoshOperator = {
  $acosh: NumberExpression,
};

/**
 * Returns the inverse hyperbolic cosine (hyperbolic arc cosine) of a value.
 * @category Operators
 * @function
 * @param {NumberExpression} numberOrExpression A number or an expression that
 * @returns {AcoshOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/acosh/|MongoDB reference}
 * for $acosh
 * @example
 * $radiansToDegrees($acosh('$x'));
 * // returns
 * { $radiansToDegrees: { $acosh: '$x' } }
 */
const $acosh = se('$acosh');

type AddOperator = {
  $add: Array<DateExpression|NumberExpression>,
};

/**
 * Adds numbers together or adds numbers and a date.
 * @category Operators
 * @function
 * @param {...NumberExpression|DateExpression} expression Number and/or date
 * expressions to add.
 * @returns {AddOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/add/|MongoDB reference}
 * for $add
 * @example <caption>Addends as arguments</caption>
 * $add(1, 2, 3);
 * // returns
 * { $add: [1, 2, 3] }
 * @example <caption>Addends as array</caption>
 * $add([1, 2, 3]);
 * // returns same as above
 */
const $add = ptafaa('$add');

/**
 * Add safetely, ensuring all expressions resolve a number. Null values resolve
 * to zero.
 * @category Safe Operators
 * @function
 * @param {...NumberExpression} expression Numbers or expressions to adds.
 * @returns {AddOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/add/|MongoDB reference}
 * for $add
 * @see $add
 * @example <caption>Non literal numbers input</caption>
 * $addSafe(1, 2, '$myVar');
 * // returns
 * { $add: [1, 2, { $ifNull: ['$myVar', 0] }] }
 * @example <caption>All literal numbers input</caption>
 * $addSafe(1, 2, 3);
 * // returns
 * { $add: [1, 2, 3] }
 * @todo Protect from non-null & non-numeric values.
 */
const $addSafe = safeNumberArgs($add);

type AddToSetOperator = {
  $addToSet: Expression,
};

/**
 * Returns an array of all unique values that results from applying an
 * expression to each document in a group.
 * @category Operators
 * @function
 * @param {Expression} expression A valid expression.
 * @returns {AddToSetOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/addToSet/|MongoDB reference}
 * for $addToSet
 * @example
 * $addToSet('$item');
 * // returns
 * { $addToSet: '$item' }
 */
const $addToSet = se('$addToSet');

type AllOperator = {
  $all: ArrayExpression,
}

/**
 * Selects documents where the value of a field is an array that contains all
 * the specified elements.
 * @function
 * @category Other Operators
 * @param {...Expression[]} expressions Match expression.
 * @returns {AllOperator} The compiled $all operator.
 */
const $all = (...expressions: Expression[] ) => ({ $all: [...expressions ]});

type AllElementsTrueOperator = {
  $allElementsTrue: Array<Expression>,
};

/**
 * Evaluates an array as a set and returns `true` if no element in the array is
 * `false`. Otherwise, returns false. An empty array returns `true`.
 * @category Operators
 * @function
 * @param {...Expression} expressions Any valid expressions.
 * @returns {AllElementsTrueOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/allElementsTrue/|MongoDB reference}
 * for $allElementsTrue
 * @example
 * $allElementsTrue('$a', '$b', ['$c', '$d']);
 * // returns
 * { $allElementsTrue: ['$a', '$b', ['$c', '$d']] }
 */
const $allElementsTrue = pta('$allElementsTrue');

type AndOperator = {
  $and: Array<Expression>,
};

/**
 * Evaluates one or more expressions and returns true if _all_ of the
 * expressions are `true` or if run with no argument expressions.
 * @category Operators
 * @function
 * @param {...Expression} expressions Any valid expressions.
 * @returns {AndOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/and/|MongoDB reference}
 * for $and
 * @example
 * $and($or('$a', '$b'), '$c');
 * // returns
 * { $and: [{ $or: ['$a', '$b'], '$c'] }
 * @example <caption>First argument array</caption>
 * $and([$or('$a', '$b'), '$c']);
 * // returns same as above
 */
const $and = ptafaa('$and');

type AnyElementTrueOperator = {
  $anyElementTrue: Array<Expression>,
};

/**
 * Evaluates an array as a set and returns `true` if any of the elements are 
 * `true`. Returns `false` otherwise.
 * @category Operators
 * @function
 * @param {...Expression} expressions Any valid expressions.
 * @returns {AnyElementTrueOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/anyElementTrue/|MongoDB reference}
 * for $anyElementTrue
 * @example
 * $anyElementsTrue('$a', '$b', ['$c', '$d']);
 * // returns
 * { $anyElementsTrue: ['$a', '$b', ['$c', '$d']] }
 */
const $anyElementTrue = pta('$anyElementTrue');

type ArrayElemAtExpression = [Array<any>, number];

type ArrayElemAtOperator = {
  $arrayElemAt: Array<ArrayElemAtExpression>,
};

/**
 * Returns the element at the specified array index.
 * @category Operators
 * @function
 * @param {ArrayExpression} arrayExpression An array or a valid expression that
 * resolves to an array.
 * @param {NumberExpression} index A number of any valid expression that
 * resolves to a number.
 * @returns {ArrayElemAtOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/arrayElemAt/|MongoDB reference}
 * for $arrayElemAt
 * @example
 * $arrayElemAt('$favorites', 0);
 * // returns
 * { $arrayElemAt: ['$favorites', 0] }
 */
const $arrayElemAt = at('$arrayElemAt');


/**
 * Returns the first element in an array.
 * @category Operators
 * @function
 * @param {ArrayExpression} inputArray An arra or a valid expression that
 * resolves to an array.
 * @returns {ArrayElemAtOperator} Returns an $arrayElemAt operator with 0 as the
 * idx parameter.
 * @example
 * $arrayElemFirst('$myArray')
 * // returns
 * { $let: { vars: { inputArray: '$myArray' }, in: { $arrayElemAt: ['$myArray', 0] } } }
 */
const $arrayElemFirst = (inputArray: ArrayExpression) => $arrayElemAt(inputArray, 0);

/**
 * Returns the last element in an array.
 * @category Operators
 * @function
 * @param {ArrayExpression} inputArray An arra or a valid expression that
 * resolves to an array.
 * @returns {ArrayElemAtOperator} Returns an $arrayElemAt operator with the idx
 * parameter calculated from the size of the array for the last element.
 * @example
 * $arrayElemLast('$myArray')
 * // returns
 * { $let: {
 *   vars: { inputArray: '$myArray' },
 *   in: { $let: {
 *     vars: { length: { $size: '$$inputArray' } },
 *     in: { $arrayElemAt: ['$$inputArray', { $subtract: ['$$length', -1] } },
 *   } },
 * } }
 */
const $arrayElemLast = (inputArray: ArrayExpression) => $let({ inputArray }).in(
  $let({ length: $size('$$inputArray') }).in(
    $arrayElemAt('$$inputArray', $cond('$$length', $decrement('$$length'), 0)),
  ),
);

type KeyValuePair = [string, any];
type KeyValueObject = {
  k: string,
  v: any,
};

type ArrayToObjectExpression = Array<KeyValuePair|KeyValueObject>;
type ArrayToObjectOperator = {
  $arrayToObject: ArrayToObjectExpression,
};

/**
 * Converts an array into a single document.
 * @category Operators
 * @function
 * @param {ArrayToObjectExpression} arrayInput An array of two-element arrays
 * where the first element is the field name, and the second element is the
 * field value OR An array of documents that contains two fields, k and v where
 * k contains the field name and v contains the value of the field.
 * @returns {ArrayToObjectOperator} Returns an $arrayToObject operator populated
 * based on input.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/arrayToObject/|MongoDB reference}
 * for $arrayToObject
 * @example <caption>Key-value object</caption>
 * $arrayToObject([
 *   { k: 'item', v: 'abc123' },
 *   { k: 'qty', v: '$qty' },
 * ]);
 * @example <caption>Key-value pair</caption>
 * $arrayToObject([
 *   ['item', 'abc123'],
 *   ['qty', '$qty'],
 * ]);
 */
const $arrayToObject = se('$arrayToObject');

type AsinOperator = {
  $asin: NumberExpression,
};

/**
 * Returns the inverse sine (arc sine) of a value.
 * @category Operators
 * @function
 * @param {NumberExpression} numberExpression Any valid expression that resolves
 * to a number between -1 and 1.
 * @returns {AsinOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/asin/|MongoDB reference}
 * for $asin
 * @example
 * $asin($divide('$side_a', '$hyoptenuse'));
 * // returns
 * { $asin: { $divide: ['$side_a', '$hypotenuse'] } }
 */
const $asin = se('$asin');

type AsinhOperator = {
  $asinh: NumberExpression,
};

/**
 * Returns the inverse hyperbolic sine (hyperbolic arc sine) of a value.
 * @category Operators
 * @function
 * @param {NumberExpression} numberExpression Any valid expression that resolves
 * to a number.
 * @returns {AsinhOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/asinh/|MongoDB reference}
 * for $asinh
 * @example
 * $asinh('$x-coordinate');
 * // returns
 * { $asinh: '$x-coordinate' }
 */
const $asinh = se('$asinh');

type AtanOperator = {
  $atan: NumberExpression,
};

/**
 * Returns the inverse tangent (arc tangent) of a value.
 * @category Operators
 * @function
 * @param {NumberExpression} numberExpression Any valid expression that resolves
 * to a number.
 * @returns {AtanOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/atan/|MongoDB reference}
 * for $atan
 * @example
 * $atan($divide('$side_b', '$side_a'));
 * // returns
 * { $atan: { $divide: ['$side_b', '$side_a'] } }
 */
const $atan = se('$atan');

type AtanhOperator = {
  $atanh: NumberExpression,
};

/**
 * Returns the inverse hyperbolic tangent (hyperbolic arc tangent) of a value.
 * @category Operators
 * @function
 * @param {NumberExpression} numberExpression Any valid expression that resolves
 * to a number between -1 and 1.
 * @returns {AtanhOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/atanh/|MongoDB reference}
 * for $atanh
 * @example
 * $atanh('$x-coordinate');
 * // returns
 * { $atanh: '$x-coordinate' }
 */
const $atanh = se('$atanh');

type AverageExpression = NumberExpression | Array<NumberExpression>;
type AverageOperator = {
  $avg: AverageExpression,
};

/**
 * Returns the average value of the numeric values ignoring non-numeric values.
 * @category Operators
 * @function
 * @param {AverageExpression} expression Varies based on the stage it is being
 * used in. See documentation.
 * @returns {AverageOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/avg/|MongoDB reference}
 * for $avg
 * @example
 * $avg('$quantity');
 * // returns
 * { $avg: '$quantity' }
 */
const $avg = se('$avg');

type BinarySizeOperator = {
  $binarySize: string | Binary | null,
};

/**
 * Returns the size of a given string or binary data value's content in bytes.
 * @category Operators
 * @function
 * @param {string | Binary | null} mixedInput Refer to documentation for
 * details.
 * @returns {BinarySizeOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/binarySize/|MongoDB reference}
 * for $binarySize
 * @example
 * $binarySize('$binary');
 * // returns
 * { $binarySize: '$binary' }
 */
const $binarySize = se('$binarySize');

/**
 * Returns the result of a bitwise and operation on an array of int or long
 * values.
 * @category Operators
 * @function
 * @param {...ObjectExpression} expressions int or long values.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/bitAnd/|MongoDB reference}
 * for $bitAnd
 * @example
 * $bitAnd('$myInt', '$myLong');
 * // returns
 * { $bitAnd: ['$myInt', '$myLong'] }
 */
const $bitAnd = ptafaa('$bitAnd');

/**
 * Returns the result of a bitwise not operation on a single int or long value.
 * @category Operators
 * @function
 * @param {...ObjectExpression} expressions int or long values.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/bitNot/|MongoDB reference}
 * for $bitAny
 * @example
 * $bitNot('$x');
 * // returns
 * { $bitNot: '$x' } 
 */
const $bitNot = se('$bitNot');

/**
 * Returns the result of a bitwise or operation on an array of int and long
 * values.
 * @category Operators
 * @function
 * @param {...ObjectExpression} expressions int or long values.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/bitOr/|MongoDB reference}
 * for $bitOr
 * @example
 * $bitOr('$myInt', '$myLong');
 * // returns
 * { $bitOr: ['$myInt', '$myLong'] }
 */
const $bitOr = ptafaa('$bitOr');

/**
 * Returns the result of a bitwise xor operation on an array of int and long
 * values.
 * @category Operators
 * @function
 * @param {...ObjectExpression} expressions int or long values.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/bitXor/|MongoDB reference}
 * for $bitXor
 * @example
 * $bitXor('$myInt', '$myLong');
 * // returns
 * { $bitXor: ['$myInt', '$myLong'] }
 */
const $bitXor = ptafaa('$bitXor');

type BsonSizeOperator = {
  $bsonSize: ObjectExpression | null,
};

/**
 * Returns the size in bytes of a given document when encoded as BSON.
 * @category Operators
 * @function
 * @param {ObjectExpression | null} expression Any valid expression that
 * resolves an object or null.
 * @returns {BsonSizeOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/bsonSize/|MongoDB reference}
 * for $bsonSize
 * @example
 * $bsonSize('$$ROOT');
 * // returns
 * { $bsonSize: '$$ROOT' }
 */
const $bsonSize = se('$bsonSize');

type CeilOperator = {
  $ceil: NumberExpression,
};

/**
 * Returns the smallest integer greater than or equal to the specified number.
 * @category Operators
 * @function
 * @param {NumberExpression} numberExpression Any valid expression that resolves
 * to a number.
 * @returns {CeilOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/ceil/|MongoDB reference}
 * for $ceil
 * @example
 * $ceil('$value');
 * // returns
 * { $ceil: '$value' }
 */
const $ceil = se('$ceil');

type CommentOperator = {
  $comment: string,
};

/**
 * Associates a comment to any expression taking a query predicate.
 * Adding a comment can make your profile data easier to interpret and trace.
 * @param {string} text Comment text
 * @returns {CommentOperator}
 */
const $comment = se('$comment');

type CmpOperator = {
  $cmp: [Expression, Expression],
};

/**
 * Compares two values.
 * @category Operators
 * @function
 * @param {Expression} expression1 Any valid expression.
 * @param {Expression} expression2 Any valid expression.
 * @returns {CmpOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/cmp/|MongoDB reference}
 * for $cmp
 * @example
 * $cmp('$qty', 250);
 * // returns
 * { $cmp: ['$qty', 250] }
 */
const $cmp = at('$cmp');

type ConcatOperator = {
  $concat: Array<StringExpression>,
};

/**
 * Concatenates strings returning the result.
 * @category Operators
 * @function
 * @param {...StringExpression} stringParts The string parts to concatenate.
 * @returns {ConcatOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/concat/|MongoDB reference}
 * for $concat
 * @example <caption>String parts as arguments</caption>
 * $concat('$item', ' - ', '$description');
 * // returns
 * { $concat: ['$item', ' - ', '$description'] }
 * @example <caption>First argument array</caption>
 * $concat(['$item', ' - ', '$description']);
 * // returns same as above
 */
const $concat = ptafaa('$concat');

/**
 * Safely concatenates values as strings returning the result.
 * @category Safe Operators
 * @function
 * @param {...Expression | Expression[]} args The parts to concatenate.
 * @returns {ConcatOperator} A $concat operator with each operand ensured to
 * return a string.
 * @see $concat
 * @see $ensureString
 * @example <caption>String parts as arguments</caption>
 * $concatSafe('$item', ' - ', '$description');
 * // wraps each non-literal string with $ensureString
 * { $concat: [$ensureString('$item'), ' - ', $ensureString('$description')] }
 * @example <caption>First argument array</caption>
 * $concatSafe(['$item', ' - ', '$description']);
 * // returns same as above
 */
const $concatSafe = (...args: Expression[]) => {
  let parts = args;
  if (args.length && Array.isArray(args[0])) parts = args[0];
  return { $concat: parts.map((expr) => $ensureString(expr)) };
};

type ConcatArraysOperator = {
  $concatArrays: Array<ArrayExpression>,
};

/**
 * Concatenates arrays.
 * @category Operators
 * @function
 * @param {...ArrayExpression} arrays The arrays to concatenate.
 * @returns {ConcatArraysOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/concatArrays/|MongoDB reference}
 * for $concatArrays
 * @example
 * $concatArrays('$myArray', [1, 2]);
 * // returns
 * { $concatArrays: ['$myArray', [1, 2]] }
 */
const $concatArrays = pta('$concatArrays');

/**
 * Safely concatenate arrays.
 * @category Safe Operators
 * @function
 * @param {...ArrayExpression} args The arrays to concatenate.
 * @returns {ConcatArraysOperator} Returns a $concatArrays operator with each
 * operand ensured to be an array.
 * @see $concatArrays
 * @example
 * $concatArraySafe([1, 2, 3], [4, 5], '$c')
 * // returns
 * { $concatArrays: [[1, 2, 3], [4, 5], { $cond: { if: { $isArray: '$c' }, then: '$c', else: [] } }] }
 */
const $concatArraysSafe = (...args: Array<any>) => ({ $concatArrays: args.map((v) => $ensureArray(v)) });

type ConditionExpression = {
  if: Expression,
  then: Expression,
  else: Expression,
};

class Condition {
  $cond: Partial<ConditionExpression>;

  constructor(ifExpr: Expression, thenExpr?: Expression, elseExpr?: Expression) {
    this.$cond = { if: ifExpr };
    if (thenExpr !== undefined) this.then(thenExpr);
    if (elseExpr !== undefined) this.else(elseExpr);
  }

  then(thenExpr: Expression) {
    this.$cond.then = thenExpr;
    onlyOnce(this, 'then');
    return this;
  }

  else(elseExpr: Expression) {
    this.$cond.else = elseExpr;
    onlyOnce(this, 'else');
    return this;
  }
}

/**
 * Evaluates a boolean expression to return one of the two specified return
 * expressions.
 * @category Operators
 * @function
 * @param {Expression} ifExpr A boolean expression.
 * @param {Expression} [thenExpr] The true case.
 * @param {Expression} [elseExpr] The false case.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/cond/|MongoDB reference}
 * for $cond
 * @example <caption>Static Notation</caption>
 * $cond($.lte($.size('$myArray'), 5), '$myArray', $.slice('$myArray', -5));
 * @example <caption>Object Notation</caption>
 * $cond($.lte($.size('$myArray'), 5)).then('$myArray').else($.slice('$myArray', -5));
 * @returns {Condition} Returns a "Condition" object that represents the $cond
 * operator whose usage varies based on the optional arguments (`thenExpr`
 * and `elseExpr`).
 * _The optional arguments are required but can be alternatively be provided
 * using a corresponding method (see the Object Notation example).
 */
const $cond = (ifExpr: Expression, thenExpr?: Expression, elseExpr?: Expression) => new Condition(ifExpr, thenExpr, elseExpr);

enum ConversionType {
  double = 1,
  string = 2,
  objectId = 7,
  bool = 8,
  date = 9,
  int = 16,
  long = 18,
  decimal = 19,
}

type ConversionTypeExpression = ConversionType | StringExpression | NumberExpression;

type ConvertExpression = {
  input: Expression,
  to: ConversionTypeExpression,
  onError?: Expression,
  onNull?: Expression,
};

class ConvertOperator {
  public $convert: Partial<ConvertExpression> = {};
  constructor(
    input: Expression,
    to?: ConversionTypeExpression,
    onErrorOrNull?: Expression,
  ) {
    this.input(input);
    if (to) this.to(to);
    if (onErrorOrNull !== undefined) this.default(onErrorOrNull);
  }

  input(input: Expression) {
    this.$convert.input = input;
    return this;
  }

  to(type: ConversionTypeExpression) {
    this.$convert.to = type;
    return this;
  }

  onError(expression: Expression) {
    this.$convert.onError = expression;
    return this;
  }

  onNull(expression: Expression) {
    this.$convert.onNull = expression;
    return this;
  }

  default(onErrorAndNull: Expression) {
    this.onError(onErrorAndNull);
    this.onNull(onErrorAndNull);
    return this;
  }
}

/**
 * Converts a value to a specified type.
 * @category Operators
 * @function
 * @param {Expression} value The value to convert. Can be any valid expression.
 * @param {ConversionTypeExpression} toType The 
 * @param {Expression} defaultValue The result if the value to convert is null
 * or induces an error.
 * @returns {ConvertOperator} A $convert operator object populated based on
 * input with additional methods for advanced usage.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/convert/|MongoDB reference}
 * for $convert
 * @example <caption>Static notation</caption>
 * $convert('$myValue', 'int');
 * // returns
 * { $convert: { input: '$myValue', to: 'int' } }
 * @example <caption>Object notation</caption>
 * $convert('$myValue', 'int').onError(-1).onNull(0);
 * // returns
 * { $convert: { input: '$myValue', to: 'int', onError: -1, onNull: 0 } }
 */
const $convert = (value: Expression, toType?: ConversionTypeExpression, defaultValue?: Expression) => new ConvertOperator(value, toType, defaultValue);

type CosOperator = {
  $cos: NumberExpression,
};

/**
 * Returns the cosine of a value that is measured in radians.
 * @category Operators
 * @function
 * @param {NumberExpression} numberExpression Any valid expression that resolves
 * to a number.
 * @returns {CosOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/cos/|MongoDB reference}
 * for $cos
 * @example
 * $cos($degreesToRadians('$angle_a'));
 * // returns
 * { $cos: { $degreesToRadians: '$angle_a' } }
 */
const $cos = se('$cos');

type CoshOperator = {
  $cosh: NumberExpression,
};

/**
 * Returns the hyperbolic cosine of a value that is measured in radians.
 * @category Operators
 * @function
 * @param {NumberExpression} numberExpression Any valid expression that resolves
 * to a number.
 * @returns {CoshOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/cosh/|MongoDB reference}
 * for $cosh
 * @example
 * $cosh($degreesToRadians('$angle'));
 * // returns
 * { $cosh: { $degreesToRadians: '$angle' } }
 */
const $cosh = se('$cosh');

type CovariancePopOperator = {
  $covariancePop: [NumberExpression, NumberExpression],
};

/**
 * Returns the population covariance of two numeric expressions that are
 * evaluated using documents in the $setWindowFields stage window.
 * @category Operators
 * @function
 * @param {NumberExpression} expression1 A number of any valid expression that
 * resolves to a number.
 * @param {NumberExpression} expression2 A number of any valid expression that
 * resolves to a number.
 * @returns {CovariancePopOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/covariancePop/|MongoDB reference}
 * for $covariancePop
 * @example
 * $covariancePop($year('$orderDate'), '$quantity');
 * // returns
 * { $covariancePop: [{ $year: '$orderDate' }, '$quantity'] }
 */
const $covariancePop = at('$covariancePop');

type CovarianceSampOperator = {
  $covarianceSamp: [NumberExpression, NumberExpression],
};

/**
 * Returns the sample covariance of two numeric expressions that are evaluated
 * using documents in the $setWindowFields stage window.
 * Non-numeric, null and missing fields are ignored.
 * @category Operators
 * @function
 * @param {NumberExpression} expression1 A number of any valid expression that
 * resolves to a number.
 * @param {NumberExpression} expression2 A number of any valid expression that
 * resolves to a number.
 * @returns {CovarianceSampOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/covarianceSamp/|MongoDB reference}
 * for $covarianceSamp
 * @example
 * $covarianceSamp($year('$orderDate'), '$quantity');
 * // returns
 * { $covarianceSamp: [{ $year: '$orderDate' }, '$quantity'] }
 */
const $covarianceSamp = at('$covarianceSamp');

type DegreesToRadiansOperator = {
  $cosh: NumberExpression,
};

/**
 * Decrement a number by 1.
 * @category Utility Operators
 * @param {NumberExpression} value A number of any valid expression that
 * resolves to a number.
 * @returns {SubtractOperator} A $subtract operator with `1` fixed as the
 * subtrahend.
 * @example
 * $decrement('$x')
 * // returns
 * { $subtract: ['$x', 1] }
 */
const $decrement = (value: Expression) => $subtract(value, 1);

/**
 * Converts the input value measured in degrees to radians.
 * @category Operators
 * @function
 * @param {NumberExpression} numberExpression Any valid expression that resolves
 * to a number.
 * @returns {DegreesToRadiansOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/degreesToRadians/|MongoDB reference}
 * for $degreesToRadians
 * @example
 * $degreesToRadians('$angle_a');
 * // returns
 * { $degreesToRadians: '$angle_a' }
 */
const $degreesToRadians = se('$degreesToRadians');

const $denseRank = ne('$denseRank');

type DivideOperator = {
  $divide: [NumberExpression, NumberExpression],
};

/**
 * Divides one number by another and returns the result.
 * @category Operators
 * @function
 * @param {NumberExpression} dividend A number of any valid expression that
 * resolves to a number.
 * @param {NumberExpression} divisor A number of any valid expression that
 * resolves to a number.
 * @returns {DivideOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/divide/|MongoDB reference}
 * for $divide
 * @example
 * $divide(9, 3);
 * // returns
 * { $divide: [9, 3 }
 */
const $divide = at('$divide');

/**
 * Safely divide one number by another. Division by zero will return the
 * `defaultValue`.
 * @category Safe Operators
 * @param {NumberExpression | null | undefined} dividend A number or any expression that resolves
 * to a number.
 * @param {NumberExpression | null | undefined} divisor A number or any expression that resolves
 * to a number.
 * @param {NumberExpression} [defaultValue] The default value if the division
 * operation cannot be performed.
 * @returns {DivideOperator} A $divide operator populated according to argument
 * input.
 * @example
 * $divideSafe('$a', '$b');
 * // returns
 * { $let: {
 *   vars: {
 *     dividend: { $ifNull: ['$a', 0] },
 *     divisor: { $ifNull: ['$b', 0] },
 *   },
 *   in: { $cond: {
 *     if: { $eq: ['$$divisor', 0] },
 *     then: 0,
 *     else: { $divide: ['$dividend', '$divisor'] },
 *   } },
 * } }
 * @example <caption>Literal input</caption>
 * $divideSafe(9, 3); // returns { $divide: [9, 3] }
 * $divideSafe(true, 3); // returns 0
 * $divideSafe(9, false); // returns 0
 * $divideSafe(9, null); // returns 0
 * $divideSafe(null, 3); // returns 0
 */
const $divideSafe = (
  dividend: NumberExpression | null | undefined | boolean,
  divisor: NumberExpression | null | undefined | boolean,
  defaultValue = '$$REMOVE',
) => {
  switch (typeof divisor) {
    case 'number':
      if (typeof dividend === 'number') return $divide(dividend, divisor);
      break;
    case 'string':
      if (divisor.match(/^[^$]/)) return 0;
      break;
    case 'boolean':
    case 'undefined':
      return 0;
    case 'object':
      if (divisor === null) return 0;
      break;
    default:
  }
  switch (typeof dividend) {
    case 'undefined':
    case 'boolean':
      return 0;
    case 'string':
      if (dividend.match(/^[^$]/)) return 0;
      break;
    case 'object':
      if (dividend === null) return 0;
      break;
    default:
  }
  return $let({
    dividend: typeof dividend === 'number' ? dividend : $ifNull(dividend, 0),
    divisor: typeof divisor === 'number' ? divisor : $ifNull(divisor, 0),
  }).in($cond($eq('$$divisor', 0), defaultValue, $divide('$$dividend', '$$divisor')));
};

type DocumentNumberOperator = {
  $documentNumber: ObjectExpression,
};

/**
 * Returns the position of a document in the $setWindowFields stage.
 * @category Operators
 * @function
 * @returns {DocumentNumberOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/documentNumber/|MongoDB reference}
 * for $documentNumber
 * @example
 * $documentNumber();
 * // returns
 * { $documentNumber: {} }
 */
const $documentNumber = ne('$documentNumber');

type EachOperator = { $each: Expression }

/**
 * Query operator. Not known to work in aggregations.
 * @function
 * @category Other Operators
 * @param {ArrayExpression} expression A valid expression that resolves to an array.
 * @returns {EachOperator} Operator with the expression embedded.
 * @deprecated Unless new information is discovered.
 */
const $each = (expression: ArrayExpression): EachOperator => ({ $each: expression });

type ElemMatchOperator = { $elemMatch: Expression };

/**
 * Matches documents that contain an array field with at least one elements
 * matching the query criteria.
 * @function
 * @category Other Operators
 * @param {ObjectExpression} query A valid expression that resolves to an array.
 * @returns {ElemMatchOperator} Operator with the expression embedded.
 * @deprecated Unless new information is discovered.
 */
const $elemMatch = (query: ObjectExpression): ElemMatchOperator => ({ $elemMatch: query });

/**
 * Ensure an expression resolves an array. Non-array values return an empty
 * array.
 * @category Utility Operators
 * @function
 * @param {Expression | null | undefined} value The value to ensure is an array.
 * @returns {Array<any>|Condition} Returns a literal array for invalid literal
 * input and a Condition for variable input.
 * @example
 * $ensureArray('$myVar');
 * // returns
 * { $let: {
 *   vars: { input: '$myVar' },
 *   in: { $cond: { if: { $isArray: '$$input' }, then: '$$input', else: [] } },
 * } }
 * @example <caption>Literal input</caption>
 * $ensureArray([1, 2, 3]); // returns [1, 2 3]
 * $ensureArray('myString'); // returns [];
 * $ensureArray(1); // returns [];
 * $ensureArray(true); // returns [];
 * $ensureArray(false); // returns [];
 * $ensureArray(undefined); // returns [];
 * $ensureArray(null); // returns [];
 */
const $ensureArray = (value: Expression | null | undefined) => {
  if (Array.isArray(value)) return value;
  switch (typeof value) {
    case 'string':
      if (value.match(/^[^$]/)) return [];
      break;
    case 'object':
      if (value === null) return [];
      break;
    case 'number':
    case 'undefined':
    case 'boolean':
      return [];
    default:
  }
    return $let({ input: value }).in($if($isArray('$$input')).then('$$input').else([]));
};

/**
 * Ensure an expression resolves a number.
 * @category Utility Operators
 * @function
 * @param {Expression | null | undefined} value The value to ensure is a number.
 * is null or induces an error.
 * @param {NumberExpression} [defaultValue] The value to return for null
 * values or when converting the input value to a double produces an error.
 * @returns {number | Condition} Returns a number of a $cond expression that
 * will convert non-numeric types to a double.
 * @example
 * $ensureNumber('$myVar');
 * // returns
 * { $cond: {
 *   if: { $in: [{ $type: '$myVar' }, ['decimal', 'int', 'double', 'long'] },
 *   then: '$myVar',
 *   else: { $convert: { input: '$myVar', to: 1, onError: 0, onNull: 0 },
 * } }
 * @example <caption>Literal input</caption>
 * $ensureNumber(123); // returns 123
 * $ensureNumber(true); // returns 1
 * $ensureNumber(false); // returns 0
 */
const $ensureNumber = (value: Expression | null | undefined, defaultValue: NumberExpression = 0) => {
  switch (typeof value) {
    case 'number': return value;
    case 'boolean': return value ? 1 : 0;
    case 'string':
      if (value.match(/^[^$]/)) return $toDouble(value);
      break;
    case 'object':
      if (value === null) return defaultValue;
      break;
    case 'undefined':
      return defaultValue;
    default:
  }
  return $let({ input: value }).in($if($isNumber('$$input')).then('$$input').else($convert('$$input', 1, defaultValue)));
};

/**
 * Ensure an expression resolves a string.
 * @category Utility Operators
 * @function
 * @param {Expression | null | undefined} value The value to ensure is of the specified type.
 * @param {StringExpression} [defaultValue] Override the default value of ""
 * if the conversion results in null or induces an error.
 * @returns {string | ConvertOperator} A literal string if the input value is a
 * literal string. Otherwise a ConvertOperator populated accordinly is returned.
 * @see $convert
 * @example
 * $ensureString('$myVariable');
 * // returns
 * { $convert: { input: '$myVariable', to: 2, onError: '', onNull: '' } },
 * @example <caption>Literal string input</caption>
 * $ensureString('literalString');
 * @example <caption>Literal number input</caption>
 * $ensureString(1); // returns "1"
 * $ensureString(1.1); // returns "1.1"
 * @example <caption>Literal boolean input</caption>
 * $ensureString(true); // returns ""
 * $ensureString(false); // return ""
 */
const $ensureString = (value: Expression | null | undefined, defaultValue: StringExpression = '') => {
  switch (typeof value) {
    case 'string':
      if (value[0] !== '$') return value;
      break;
    case 'number':
      return `${value}`;
    case 'boolean':
      return value ? 'true' :'false';
    case 'object':
      if (value === null) return defaultValue;
      break;
    case 'undefined':
      return defaultValue;
    default:
  }
  return $convert(value as Expression, ConversionType.string, defaultValue);
};

type EqOperator = {
  $eq: [Expression, Expression],
};

/**
 * Compares two values and returns `true` when the values are equivalent and
 * `false` otherwise.
 * @category Operators
 * @function
 * @param {Expression} expression1 First expression.
 * @param {Expression} expression2 Second expression.
 * @returns {EqOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/eq/|MongoDB reference}
 * for $eq
 * @example
 * $eq('$qty', 250);
 * // returns
 * { $eq: ['$qty', 250] }
 */
const $eq = at('$eq');

type ExistsOperator = {
  $exists: boolean | Expression,
};

/**
 * Matches documents that contain or do not contain a specified field, including
 * documents where the field value is null.
 * @param {Expression} match Boolean expression.
 * @returns {ExistsOperator}
 */
const $exists = se('$exists');

type ExpOperator = {
  $exp: NumberExpression,
};

/**
 * Raises Euler's number to the specified exponent and returns the result.
 * @function
 * @category Operators
 * @param {NumberExpression} numberExpression Any valid expression that resolves
 * to a number.
 * @returns {ExpOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/exp/|MongoDB reference}
 * for $exp
 * @example
 * $exp('$interestRate');
 * // returns
 * { $exp: '$interestRate' }
 */
const $exp = se('$exp');

type ExprOperator = {
  $expr: Expression,
};

/**
 * Allows the use of some aggregation operators otherwise unavailable to the
 * aggregation stage.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid aggregation expression.
 * @returns {ExprOperator} The compiled $expr expression.
 */
const $expr = (expression: Expression): ExprOperator => ({ $expr: expression });

type FilterExpression = {
  input: ArrayExpression,
  cond: Expression,
  as?: string,
  limit?: number,
};

class FilterOperator {
  $filter: Partial<FilterExpression> = {};
  constructor(
    inputExpr: ArrayExpression,
    asExpr?: string,
    cond?: Expression,
    limit?: number,
  ) {
    this.input(inputExpr);
    if (asExpr) this.as(asExpr);
    if (cond) this.cond(cond);
    if (limit !== undefined) this.limit(limit);
  }

  input(value: ArrayExpression) {
    this.$filter.input = value;
    return this;
  }

  as(name: string) {
    this.$filter.as = name;
    return this;
  }

  cond(expression: Expression) {
    this.$filter.cond = expression;
    return this;
  }

  limit(value: number) {
    this.$filter.limit = value;
    return this;
  }
}

/**
 * Filters an array based on the specified condition returning the items that
 * match the condition.
 * @category Operators
 * @param {ArrayExpression} inputExpr An expression that resolves to an array.
 * @param {string} [asName] Optional. A name for the value that represents each
 * element of the input array. If no name is specified, the variable name
 * defaults to `this`.
 * @param {Expression} [condExpr] An expression that resolves to boolean and can
 * references each elements of the input array with the variable name specified
 * in as().
 * @param {number} [limit] Optional. A number expression that restricts the
 * number of matching array elements to return.
 * @returns {FilterOperator} A FilterOperator
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/filter/|MongoDB reference}
 * for $filter
 * @example <caption>Static notation</caption>
 * $filter('$myArray', 'item', '$$item.sold', 10);
 * // returns
 * { $filter: { input: '$myArray', as: 'item', cond: '$$item.sold', limit: 10 } }
 * @example <caption>Object notation</caption>
 * $filter('$myArray').as('item').cond('$$item.sold').limit(10);
 * // returns same as above
 */
const $filter = (
  inputExpr: ArrayExpression,
  asName?: string,
  condExpr?: Expression,
  limit?: number,
) => new FilterOperator(inputExpr, asName, condExpr, limit);

type FindParams = [
  ArrayExpression,
  string,
  Expression,
  number?,
];

const $find = (...args: FindParams) => $arrayElemAt($filter(...args), 0);

type FirstOperator = {
  $first: Expression,
};

/**
 * Returns the result of an expression for the first document in a group of
 * documents.
 * @category Operators
 * @function
 * @param {Expression} expression Expression that resolves a value from the 
 * document.
 * @returns {FirstOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/first/|MongoDB reference}
 * for $first
 * @example
 * $first('$date');
 * // returns
 * { $first: '$date' }
 */
const $first = se('$first');

type FloorOperator = {
  $floor: NumberExpression,
};

/**
 * Returns the largest integer less than or equal to the specified number.
 * @category Operators
 * @function
 * @param {NumberExpression} numberExpression Any valid expression that resolves
 * to a number.
 * @returns {FloorOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/floor/|MongoDB reference}
 * for $floor
 * @example
 * $floor('$value');
 * // returns
 * { $floor: '$value' }
 */
const $floor = se('$floor');

// TODO
const $getField = (field: string, input: ObjectExpression|undefined = undefined) => {
  return  { $getField: input ? { field, input } : { field } };
};

// TODO
const $gt = taf('$gt');

// TODO
const $gte = taf('$gte');

/**
 * Shortcut object-notation for the $cond operation (if/then/else).
 * @category Utility Operators
 * @function
 * @param {Expression} ifExpr A boolean expression.
 * @returns {Condition} A Condition object that resembles the $cond operation
 * whose then and else case should be set using the corresponding methods.
 * @see $cond
 * @example
 * $if($.lte($.size('$myArray'), 5), '$myArray', $.slice('$myArray', -5));
 */
const $if = (ifExpr: Expression) => new Condition(ifExpr);

type IfNullOperator = {
  $ifNull: Array<Expression>,
};

/**
 * Evaluates input expressions for null values and returns the first non-null 
 * value.
 * TODO - Should support more than two args.
 * @category Operators
 * @function
 * @param {Expression} input Input value.
 * @param {Expression} replacement Replacement value.
 * @returns {IfNullOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/ifNull/|MongoDB reference}
 * for $ifNull
 * @example
 * $ifNull('$myArray', []);
 * // returns
 * { $ifNull: ['$myArray', []] }
 */
const $ifNull = at('$ifNull');

/**
 * Increment a number by 1.
 * @category Utility Operators
 * @param {NumberExpression} value A number or any valid expression that
 * resolves to a number.
 * @returns {AddOperator} An $add operator with a fixed added of 1.
 * @example
 * $increment('$x')
 * // returns
 * { $add: ['$x', 1] }
 */
const $increment = (value: NumberExpression) => $add(value, 1);

// TODO
const $in = taf('$in');

// TODO
// * @category Safe Operators
const $inSafe = (...args: any[]) => {
  if (args.length === 2) {
    const [val, arr] = args;
    return $in(val, $ifNull(arr, []));
  }
  return $in(...args);
};

// TODO
// const $function

type IsArrayOperator = {
  $isArray: Expression,
};

/**
 * Determines if the operand is an array.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid expression.
 * @returns {IsArrayOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/isArray/|MongoDB reference}
 * for $isArray
 * @example
 * $isArray('$value');
 * // returns
 * { $isArray: '$value' }
 */
const $isArray = se('$isArray');

type IsNumberOperator = {
  $isNumber: Expression,
};

/**
 * Checks if the specified expression resolves to a numeric BSON type.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid expression.
 * @returns {IsNumberOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/isNumber/|MongoDB reference}
 * for $isNumber
 * @example
 * $isNumber('$value');
 * // returns
 * { $isNumber: '$value' }
 */
const $isNumber = se('$isNumber');

type LastOperator = {
  $last: Expression,
};

/**
 * Returns the result of an expression of the last document in a group of 
 * documents. Only meaningful when documents are in a defined order.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid expression.
 * @returns {LastOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/last/|MongoDB reference}
 * for $last
 * @example
 * $last('$myArr');
 * // return
 * { $last: '$myArray' }
 */
const $last = se('$last');

type LetExpression = {
  vars: Expression,
  in: Expression,
};

class LetVarsIn {
  $let: Partial<LetExpression>;

  constructor(varsExpr?: Expression, inExpr?: Expression) {
    this.$let = {}
    if (varsExpr) this.vars(varsExpr);
    if (inExpr) this.in(inExpr);
  }

  vars(varsExpr: Expression) {
    this.$let.vars = varsExpr;
    onlyOnce(this, 'vars');
    return this;
  }

  in(inExpr: Expression) {
    this.$let.in = inExpr;
    onlyOnce(this, 'in');
    return this;
  }
}

/**
 * Binds variables for use in the specified expression and returns the result of
 * the expression.
 * @category Operators
 * @function
 * @param {Expression} varsExpr Assign for the variables accessible in the in
 * expression.
 * @param {Expression} [inExpr] The expression to evaluate.
 * @returns {LetVarsIn} Returns a "LetVarsIn" object that resembles the $let
 * operator whose usage varies based on the optional arguments.
 * _The optional arguments can be alternatively be provided using a
 * corresponding method (see the Object Notation example).
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/let/|MongoDB reference}
 * for $let
 * @example <caption>Static Notation</caption>
 * $let({ myVar: 'val' }, $.concat('myVar equals: ', '$$myVar'));
 * @example <caption>Object Notation #1</caption>
 * $let({ myVar: 'val' }}).in($.concat('myVar equals: ', '$$myVar'));
 * @example <caption>Object Notation #2</caption>
 * $let().vars({ myVar: 'val' }}).in($.concat('myVar equals: ', '$$myVar'));
 * @example <caption>Return value of all above examples</caption>
 * { $let: { vars: { myVar: 'val', in: { $concat: ['myVar equals: ', '$$myVar'] } } } }
 */
const $let = (varsExpr?: Expression, inExpr?: Expression) => new LetVarsIn(varsExpr, inExpr);

type LinearFillOperator = {
  $linearFill: Expression,
};

/**
 * Fills null and missing fields in a window using linear interpolation based on
 * surrounding field values.
 * Only available in the $setWindowFields stage.
 * @category Operators
 * @function
 * @param {Expression} expression A valid expression.
 * @returns {LinearFillOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/linearFill/|MongoDB reference}
 * for $linearFill
 * @example
 * $linearFill('$price');
 * // returns
 * { $linearFill: '$price' }
 */
const $linearFill = se('$linearFill');

type LiteralOperator = {
  $literal: any,
};

/**
 * Returns a value without parsing. Use for values that the aggregation pipeline
 * may interpret as an expression.
 * @category Operators
 * @function
 * @param {any} value Any value
 * @returns {LiteralOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/literal/|MongoDB reference}
 * for $literal
 * @example
 * $literal(1);
 * // returns
 * { $literal: 1 }
 */
const $literal = se('$literal');

type LocfOperator = {
  $locfOperator: Expression,
};

/**
 * Last observation carried forward. Sets values for null and missing fields in
 * a window to the last non-null value for the field.
 * Only available in the $setWindowFields stage.
 * @category Operators
 * @function
 * @param {Expression} expression A valid expression.
 * @returns {LocfOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/locf/|MongoDB reference}
 * for $locf
 * @example
 * $locf('$price');
 * // returns
 * { $locf: '$price' }
 */
const $locf = se('$locf');

type LogOperator = {
  $log: [NumberExpression, NumberExpression],
};

/**
 * Calculates the log of a number in the specified base and returns the result
 * as a double.
 * @category Operators
 * @function
 * @param {NumberExpression} expression1 A number of any valid expression that
 * resolves to a non-negative number.
 * @param {NumberExpression} expression2 A number of any valid expression that
 * resolves to a positive number greater than 1.
 * @returns {LogOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/log/|MongoDB reference}
 * for $log
 * @example
 * $log('$int', 2);
 * // returns
 * { $log: ['$int', 2] }
 */
const $log = at('$log');

type Log10Operator = {
  $log10: NumberExpression,
};

/**
 * Calculates the log base 10 of a number and returns the result as a double.
 * @category Operators
 * @function
 * @param {NumberExpression} numberOrExpression A number or an expresison that
 * resolves to a number.
 * @returns {Log10Operator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/log10/|MongoDB reference}
 * for $log10
 * @example
 * $log10('$H30');
 * // returns
 * { $log10: '$H30' }
 */
const $log10 = se('$log10');

// TODO
const $lt = taf('$lt');

// TODO
const $lte = taf('$lte');

type MapExpression = {
  input: ArrayExpression,
  as: string,
  in: Expression,
};

class MapOperator {
  $map: Partial<MapExpression> = {};
  constructor(inputExpr: ArrayExpression, asName?: string, inExpr?: Expression) {
    this.input(inputExpr);
    if (asName) this.as(asName);
    if (inExpr) this.in(inExpr);
  }

  input(inputExpr: ArrayExpression) {
    this.$map.input = inputExpr;
    onlyOnce(this, 'input');
    return this;
  }

  as(name: string) {
    this.$map.as = name;
    onlyOnce(this, 'as');
    return this;
  }

  in(expression: Expression) {
    this.$map.in = expression;
    onlyOnce(this, 'in');
    return this;
  }
}

/**
 * Applies an expression to each item in an array and returns an array with the
 * results.
 * @category Operators
 * @param {ArrayExpression} inputExpr An expression that resolves to an array.
 * @param {string} [asName] A name for the variable that represents each
 * individual element of the input array.
 * @param {Expression} [inExpr] An expression that is applied to each element of
 * the input array.
 * @returns {MapOperator} A MapOperator object populated based on input.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/map/|MongoDB reference}
 * for $map
 * @example <caption>Static notation</caption>
 * $map('$myArray', 'item', '$$item.name');
 * // returns
 * { $map: { input: '$myArray', as: 'item', in: '$$item.name' } }
 */
const $map = (inputExpr: ArrayExpression, asName?: string, inExpr?: Expression) => new MapOperator(inputExpr, asName, inExpr);

// TODO
const $max = taf('$max');

// TODO - $mergeObjects accepts only one arg when used an accumulator
const $mergeObjects = ptafaa('$mergeObjects');

enum MetaDataKeyword {
  textScore,
  indexKey,
}

type MetaOperator = {
  $meta: MetaDataKeyword,
};

/**
 * Returns the metadata associated with a document when performing a search.
 * @category Operators
 * @function
 * @param {MetaDataKeyword} metaDataKeyword
 * @returns {MetaOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/meta/|MongoDB reference}
 * for $meta
 * @example
 * $meta('textScore');
 * // returns
 * { $meta: 'textScore' }
 */
const $meta = se('$meta');

// TODO
const $min = taf('$min');

type ModOperator = {
  $mod: [NumberExpression, NumberExpression],
};

/**
 * Divides one number by another and returns the remainder.
 * @category Operators
 * @function
 * @param {NumberExpression} expression1 A number of any valid expression that
 * resolves to a number.
 * @param {NumberExpression} expression2 A number of any valid expression that
 * resolves to a number.
 * @returns {ModOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/mod/|MongoDB reference}
 * for $mod
 * @example
 * $mod('$hours', '$tasks');
 * // returns
 * { $mod: ['$hours', '$tasks'] }
 */
const $mod = at('$mod');

type MultiplyOperator = {
  $multiply: NumberExpression[],
};

/**
 * Multiplies numbers together and returns the result.
 * @category Operators
 * @function
 * @param {...NumberExpression} expressions Any valid expression that resolves
 * to a number.
 * @returns {MultiplyOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/multiply/|MongoDB reference}
 * for $multiply
 * @example
 * $multiply(1, 2, 3, 4);
 * // returns
 * { $multiply: [1, 2, 3, 4] }
 */
const $multiply = ptafaa('$multiply');

/**
 * Safely multiply numbers together.
 * @category Safe Operators
 * @function
 * @param {...NumberExpression} expressions Any valid expression that resolves
 * to a number.
 * @returns {MultiplyOperator}
 * @see $multiply
 * @example
 * $multiplySafe('$a', 2, '$c');
 * // returns
 * { $multiply: [{ $ifNull: ['$a', 0] }, 2, { $ifNull: ['$c', 0] } }
 * @example <caption>Literal input</caption>
 * $multiplySafe(1, 2, 3, 4); // returns { $multiply: [1, 2, 3, 4] }
 * $multiplySafe(1, true); // returns 0;
 * $multiplySafe(false, 2); // returns 0;
 * $multiplySafe(null, 2); // returns 0;
 * $multiplySafe(1, undefined); // returns 0;
 */
const $multiplySafe = safeNumberArgs($multiply);

type NeOperator = {
  $ne: [Expression, Expression],
};

/**
 * Compares two values and returns true when the values are not equivalent and
 * false when the values are equivalent.
 * @category Operators
 * @function
 * @param {Expression} expression1 Any valid expression.
 * @param {Expression} expression2 Any valid expression.
 * @returns {NeOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/ne/|MongoDB reference}
 * for $ne
 * @example
 * $ne('$qty', 250);
 * // returns
 * { $ne: ['$qty', 250] }
 */
const $ne = at('$ne');

// TODO
const $nin = taf('$nin');

// TODO
// * @category Safe Operators
const $ninSafe = (...args: any[]) => {
  if (args.length === 2) {
    const [val, arr] = args;
    return $in(val, $ifNull(arr, []));
  }
  return $in(...args);
};

type NorOperator = {
  $not: Expression,
};

/**
 * Evalutes a boolean and returns the opposite boolean value.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid expression.
 * @returns {NorOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/nor/|MongoDB reference}
 * for $nor
 * @example
 * $nor('$a', '$b');
 * // returns
 * { $nor: ['$a', '$b'] }
 */
const $nor = se('$nor');

type NotOperator = {
  $not: Expression,
};

/**
 * Evalutes a boolean and returns the opposite boolean value.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid expression.
 * @returns {NotOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/not/|MongoDB reference}
 * for $not
 * @example
 * $not('$myVar');
 * // returns
 * { $not: '$myVar' }
 */
const $not = se('$not');

type ObjectToArrayOperator = {
  $objectToArray: ObjectExpression,
};

/**
 * Converts a document to an array.
 * @category Operators
 * @function
 * @param {ObjectExpression} object Any valid expression that evaluates to an
 * expression.
 * @returns {ObjectToArrayOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/objectToArray/|MongoDB reference}
 * for $objectToArray
 */
const $objectToArray = se('$objectToArray');

// TODO
const $or = ptafaa('$or');

type PowOperator = {
  $pow: [NumberExpression, NumberExpression],
};

/**
 * Raises a number to the specified exponent and retuns the result.
 * @category Operators
 * @function
 * @param {NumberExpression} expression1 A number of any valid expression that
 * resolves to a number.
 * @param {NumberExpression} expression2 A number of any valid expression that
 * resolves to a number.
 * @returns {PowOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/pow/|MongoDB reference}
 * for $pow
 */
const $pow = at('$pow');

type PushOperator = {
  $push: Expression,
};

/**
 * Returns an array of all values that result from applying an expression to
 * documents.
 * @category Operators
 * @function
 * @param expression Expression
 * @param {Expression} expression A valid expression.
 * @returns {PushOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/push/|MongoDB reference}
 * for $push
 */
const $push = se('$push');

type RadiansToDegreesOperator = {
  $radiansToDegrees: NumberExpression,
};

/**
 * Converts an input value measured in radians to degrees.
 * @category Operators
 * @function
 * @param {NumberExpression} numberOrExpression A number or an expresison that
 * resolves to a number.
 * @returns {RadiansToDegreesOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/radiansToDegrees/|MongoDB reference}
 * for $radiansToDegrees
 */
const $radiansToDegrees = se('$radiansToDegrees');

// TODO
const $rand = ne('$rand');

// TODO
const $rank = ne('$rank');

// TODO
const $reduce = (input: ObjectExpression, initialValue: Expression, inExpr: Expression) => ({
  $reduce: { input, initialValue, in: inExpr },
});

type RoundOperator = {
  $round: NumberExpression,
};

/**
 * Rounds a number to a whole integer or to a specified decimal place.
 * @category Operators
 * @function
 * @param {NumberExpression} value A number of any valid expression that
 * resolves to a number.
 * @param {NumberExpression} [places] A number of any valid expression that
 * resolves to an integer between -20 and 100. Defaults to 0 if unspecified.
 * @returns {RoundOperator} A $round operator populated with argument input.
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/round/|MongoDB reference}
 * for $round
 * @example
 * $round(10.5) // 10
 * $round(11.5) // 12
 * $round(12.5) // 12
 * $round(13.5) // 14
 */
const $round = (value: Expression, places = 0) => ({ $round: [value, places] });

/**
 * Rounds a number to a specified decimal place.
 * @category Utility Operators
 * @function
 * @param {NumberExpression} value A number of any valid expression that
 * resolves to a number.
 * @param {NumberExpression} [places] A number of any valid expression that
 * resolves to an integer between -20 and 100. Defaults to 0 if unspecified.
 * @returns {LetVarsIn} Returns an expression that rounds the value accordingly.
 * @see $round
 * @see {@link https://www.npmjs.com/package/mongo-round}
 * @see {@link https://stackoverflow.com/questions/17482623/rounding-to-2-decimal-places-using-mongodb-aggregation-framework}
 * @example <caption>Zero decimal places</caption>
 * $roundStandard('$myVal');
 * // returns
 * { $let: {
 *   input: '$myVal',
 *   in: { $let: {
 *     vars: {
 *       val: { $add: [
 *         '$$input',
 *         { $cond: { if: { $gte: ['$$input', 0] }, then: 0.5, else: -0.5 } },
 *       ] },
 *     },
 *     in: { $subtact: ['$$val', { $mod: ['$$val', 1] }] },
 *   } },
 * } }
 * @example <caption>With decimal places</caption>
 * $roundStandard('$myVal', 2);
 * // returns
 * { $let: {
 *   input: '$myVal',
 *   in: { $let: {
 *     vars: {
 *       val: { $add: [
 *         { $multiply: ['$$input', 2] },
 *         { $cond: { if: { $gte: ['$$input', 0] }, then: 0.5, else: -0.5 } },
 *       ] },
 *     },
 *     in: { $divide: [{ $subtract: ['$$val', { $mod: ['$$val', 1] }] }, 2] },
 *   } },
 * } },
 */
const $roundStandard = (value: Expression, places = 0) => {
  const expr = $let({ input: value });
  if (places) {
    const multiplier = Math.pow(10, places || 0);
    return expr.in($let({
      val: $add(
        $multiply('$$input', multiplier),
        $if($gte('$$input', 0)).then(0.5).else(-0.5),
      ),
    }).in($divide($subtract('$$val', $mod('$$val', 1)), multiplier)));
  }
  return expr.in($let({
    val: $add('$$input', $if($gte('$$input', 0)).then(0.5).else(-0.5)),
  }).in($subtract('$$val', $mod('$$val', 1))));
};

type SampleRateOperator = {
  $sampleRate: number,
};

/**
 * Matches a random selection of input documents.
 * @category Operators
 * @function
 * @param {number} value A floating point number between 0 and 1.
 * @returns {SampleRateOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/sampleRate/|MongoDB reference}
 * for $sampleRate
 */
const $sampleRate = se('$sampleRate');

type SetDifferenceOperator = {
  $setDifference: [ArrayExpression, ArrayExpression],
};

/**
 * Takes two sets and returns an array containing the elements that only exist
 * in the first set.
 * @category Operators
 * @function
 * @param {ArrayExpression} expression1 An array or a valid expression that
 * resolves to an array.
 * @param {ArrayExpression} expression2 An array or a valid expression that
 * resolves to an array.
 * @returns {SetDifferenceOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/setDifference/|MongoDB reference}
 * for $setDifference
 */
const $setDifference = at('$setDifference');

// TODO
const $setEquals = pta('$setEquals');

// TODO
const $setField = (input: ObjectExpression, field: string, value: Expression) => ({
  $setField: { field, input, value },
});

// TODO
const $setIntersection = pta('$setIntersection');

type SetIsSubsetOperator = {
  $setIsSubset: [ArrayExpression, ArrayExpression],
};

/**
 * Takes two arrays and returns true when the first array is a subset of the
 * second, including when the first array equals the second array, and false
 * otherwise.
 * @category Operators
 * @function
 * @param {ArrayExpression} expression1 An array or a valid expression that
 * resolves to an array.
 * @param {ArrayExpression} expression2 An array or a valid expression that
 * resolves to an array.
 * @returns {SetIsSubsetOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/setIsSubset/|MongoDB reference}
 * for $setIsSubset
 */
const $setIsSubset = at('$setIsSubset');

// TODO
const $setUnion = pta('$setUnion');

type SinOperator = {
  $sin: NumberExpression,
};

/**
 * Returns the sine of a value that is measured in radians.
 * @category Operators
 * @function
 * @param {NumberExpression} numberOrExpression A number or an expresison that
 * resolves to a number.
 * @returns {SinOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/sin/|MongoDB reference}
 * for $sin
 */
const $sin = se('$sin');

type SinhOperator = {
  $sinh: NumberExpression,
};

/**
 * Returns the hyperbolic sine of a value that is measured in radians.
 * @category Operators
 * @function
 * @param {NumberExpression} numberOrExpression A number or an expresison that
 * resolves to a number.
 * @returns {SinhOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/sinh/|MongoDB reference}
 * for $sinh
 */
const $sinh = se('$sinh');

type SizeOperator = {
  $size: ArrayExpression,
};

/**
 * Counts and returns the total number of items in an array.
 * @category Operators
 * @function
 * @param {ArrayExpression} arrayExpression An array or a valid expression that
 * resolves to an array.
 * @returns {SizeOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/size/|MongoDB reference}
 * for $size
 * @example
 * $size('$myArray');
 * // returns
 * { $size: '$myArray' }
 */
const $size = se('$size');

const $slice = pta('$slice');

type SplitOperator = {
  $split: [StringExpression, StringExpression],
};

/**
 * Divides a string into an array of substrings based on a delimeter.
 * @category Operators
 * @function
 * @param {StringExpression} value The string to be split.
 * @param {StringExpression} delimeter The delimeter to use.
 * @returns {SplitOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/split/|MongoDB reference}
 * for $split
 * @example
 * $split('June-15-2013', '-');
 * // returns
 * { $split: ['June-15-2013', '-'] }
 */
const $split = at('$split');

/**
 * Calculates the square root of a positive number and returns the result as a
 * double.
 * @category Operators
 * @function
 * @param {NumberExpression} numberOrExpression A number or an expresison that
 * resolves to a number.
 * @returns {SortStage}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/sqrt/|MongoDB reference}
 * for sqrt
 */
const $sqrt = se('$sqrt');

type StrcasecmpOperator = {
  $strcasecmp: [StringExpression, StringExpression],
};

/**
 * Performs case-insensitive comparison of two strings.
 * @category Operators
 * @function
 * @param {StringExpression} exression1 A string or any valid expression that
 * resolves to a string.
 * @param {StringExpression} exression2 A string or any valid expression that
 * resolves to a string.
 * @returns {StrcasecmpOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/strcasecmp/|MongoDB reference}
 * for $strcasecmp
 * @example
 * $strcasecmp('$quarter', '13q4');
 * // returns
 * { $strcasecmp: ['$quarter', '13q4'] }
 */
const $strcasecmp = at('$strcasecmp');

type StrLenBytesOperator = {
  $strLenBytes: StringExpression,
};

/**
 * Returns the number of UTF-9 encoded bytes in the specified string.
 * @category Operators
 * @function
 * @param {StringExpression} expression A string or a valid expression that
 * resolves to a string.
 * @returns {StrLenBytesOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/strLenBytes/|MongoDB reference}
 * for $strLenBytes
 * @example
 * $strLenBytes('cafeteria');
 * // returns
 * { $strLenBytes: 'cafeteria' }
 */
const $strLenBytes = se('$strLenBytes');

type StrLenCpOperator = {
  $strLenCP: StringExpression,
};

/**
 * Returns the number of UTF-8 code pints in the specified string.
 * @category Operators
 * @function
 * @param {StringExpression} expression A string or a valid expression that
 * resolves to a string.
 * @returns {StrLenCpOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/strLenCP/|MongoDB reference}
 * for $strLenCP
 * @example
 * $strLenCP('cafeteria');
 * // returns
 * { $strLenCP: 'cafeteria' }
 */
const $strLenCP = se('$strLenCP');

type SubtractOperator = {
  $subtract: [NumberExpression, NumberExpression],
};

/**
 * Subtracts two numbers to return the difference, or two dates to return the
 * difference in milliseconds, or a date and a number in milliseconds to return
 * the resulting date.
 * @category Operators
 * @function
 * @param {NumberExpression} minuend A number of any valid expression that
 * resolves to a number.
 * @param {NumberExpression} subtrahend number of any valid expression that
 * resolves to a number.
 * @returns {SubtractOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/subtract/|MongoDB reference}
 * for $subtract
 * @example
 * $subtract('$price', '$discount');
 * // returns
 * { $subtract: ['$price', '$discount'] }
 */
const $subtract = at('$subtract');

/**
 * Safely subtract two numbers returning the difference.
 * @category Safe Operators
 * @function
 * @param {NumberExpression | null | undefined | boolean} minuend A number of any valid expression that
 * resolves to a number.
 * @param {NumberExpression | null | undefined | boolean} subtrahend number of any valid expression that
 * resolves to a number.
 * @returns {SubtractOperator | number}
 * @see $subtract
 * @example
 * $subtractSafe('$a', '$b')
 * // returns
 * { $subtract: [{ $ifNull: ['$a', 0] }, { $ifNull: ['$b', 0] }] }
 * @example <caption>Literal input</caption>
 * $subtractSafe('$a', 1); // returns { $subtract: [{ $ifNull: ['$a', 0] }, 1] }
 * $subtractSafe(3, '$b'); // returns { $subtract: [3, { $ifNull: ['$b', 0] }] }
 * $subtractSafe(3, 1); // returns { $subtract: [3, 1] }
 * $subtractSafe(true, 1); // returns { $subtract: [1, 1]
 * $subtractSafe(3, false); // returns { $subtract: [3, 0] }
 * $subtractSafe(3, undefined); // returns { $subtract: [3, 0] }
 * $subtractSafe(3, null); // returns { $subtract: [3, 0] }
 * $subtractSafe(undefined, 1); // returns { $subtract: [0, 1] }
 * $subtractSafe(null, 1); // returns { $subtract: [0, 1] }
 */
const $subtractSafe = safeNumberArgs($subtract);

const $substr = pta('$substr');
const $substrBytes = pta('$substrBytes');
const $substrCP = pta('$substrCP');

type SumOperator = {
  $sum: Expression,
};

/**
 * Calculates and returns the collective sum of numeric values. Non-numeric
 * values are ignored.
 * @category Operators
 * @function
 * @param {Expression} expression Refer to documentation.
 * @returns {SumOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/sum/|MongoDB reference}
 * for $sum
 * @example <caption>Single operand</caption>
 * $sum('$value');
 * // returns
 * { $sum: '$value' }
 * @todo Support array operands
 */
const $sum = se('$sum');

type Branch = {
  case: Expression,
  then: Expression,
};

const branch = (caseExpr: Expression, thenExpr: Expression): Branch =>
  ({ case: caseExpr, then: thenExpr });

type SwitchExpression = {
  branches: Branch[];
  default?: Expression;
};

/**
 * @typedef {Expression | Array<Branch>} DefaultOrBranches
 * @description The default path or an array of (switch) branches.
 */
type DefaultOrBranches = Expression | Branch[];

class Switch {
  public $switch: Partial<SwitchExpression> = {};

  constructor(arg1?: DefaultOrBranches, arg2?: DefaultOrBranches) {
    const args = [arg1, arg2].filter((v) => v !== undefined);
    if (args.length >= 2) {
      if (args.filter((v) => Array.isArray(v)).length > 1) {
        throw new TypeError(`Multiple switch branches input. Only one is supported.`);
      }
      if (args.filter((v) => !Array.isArray(v)).length > 1) {
        throw new TypeError(`Multiple switch defaults input. Only one is supported.`);
      }
    }
    while (args.length) {
      const arg = args.shift();
      if (Array.isArray(arg)) {
        this.branches(...arg as Branch[]);
      } else {
        this.default(arg as Expression);
      }
    }
  }

  branches(...args: Branch[]) {
    this.$switch.branches = args;
    return this;
  }

  branch(caseExpr: Expression, thenExpr: Expression) {
    if (this.$switch.branches === undefined) {
      this.$switch.branches = [];
    }
    this.$switch.branches.push({ case: caseExpr, then: thenExpr });
    return this;
  }

  default(defaultReturn: Expression) {
    this.$switch.default = defaultReturn;
    onlyOnce(this, 'default');
    return this;
  }
}

/**
 * Evaluates a series of case expressions. When it finds an expression which
 * evaluates to true, $switch executes a specified expression and breaks out of
 * the control flow.
 * @category Operators
 * @function
 * @param {DefaultOrBranches} [arg1] Default path or array of branches.
 * @param {DefaultOrBranches} [arg2] Default path or array of branches.
 * @returns {Switch} Returns a Switch object that resembles the $switch operator
 * whose usage varies based on the optional argument input.
 * _Optional arguments should be provided through their corresponding methods
 * to complete the expression._
 * @example <caption>Static Notation</caption>
 * $switch('$$PRUNE', [
 *   $case('$user.isAdministrator', '$$KEEP'),
 *   $case('$document.hasMore', '$$DESCEND'),
 * ]);
 * @example <caption>With arguments inverted</caption>
 * $switch([
 *   $case('$user.isAdministrator', '$$KEEP'),
 *   $case('$document.hasMore', '$$DESCEND'),
 * ], '$$PRUNE');
 * @example <caption>Object notation</caption>
 * $switch()
 *  .case('$user.isAdministor', '$$KEEP')
 *  .case('$document.hasMore', '$$DESCEND')
 *  .default('$$PRUNE');
 * @example <caption>Hybrid Notation</caption>
 * $switch('$$PRUNE')
 *  .case('$user.isAdministor', '$$KEEP')
 *  .case('$document.hasMore', '$$DESCEND');
 * @example <caption>Return value of all above examples</caption>
 * {
 *   $switch: {
 *     branches: [
 *       { cond: '$user.isAdministrator', then: '$$KEEP' },
 *       { cond: '$document.hasMore', then: '$$DESCEND' },
 *     ],
 *     default: '$$PRUNE',
 *   },
 * }
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/switch/|MongoDB reference}
 * for $switch
 */
const $switch = (arg1?: DefaultOrBranches, arg2?: DefaultOrBranches) => new Switch(arg1, arg2);

type TanOperator = {
  $tan: NumberExpression,
};

/**
 * Returns the tangent of a value that is measured in radians.
 * @category Operators
 * @function
 * @param {NumberExpression} numberOrExpression A number or an expresison that
 * resolves to a number.
 * @returns {TanOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/tan/|MongoDB reference}
 * for $tan
 */
const $tan = se('$tan');

type TanhOperator = {
  $tanh: NumberExpression,
};

/**
 * Returns the hyperbolic tangent of a value that is measured in radians.
 * @category Operators
 * @function
 * @param {NumberExpression} numberOrExpression A number or an expresison that
 * resolves to a number.
 * @returns {TanhOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/tanh/|MongoDB reference}
 * for $tanh
 */
const $tanh = se('$tanh');

type ToBoolOperator = {
  $toBool: Expression,
};

/**
 * Converts a value to a boolean.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid expression.
 * @returns {ToBoolOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/toBool/|MongoDB reference}
 * for $toBool
 */
const $toBool = se('$toBool');

type ToDateOperator = {
  $toDate: Expression,
};
/**
 * Converts a value to a date. Will produce an error if the value cannot be
 * converted.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid expression.
 * @returns {ToDateOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/toDate/|MongoDB reference}
 * for $toDate
 */
const $toDate = se('$toDate');

type ToDecimalOperator = {
  $toDecimal: Expression,
};

/**
 * Converts a value to a decimal. Throws an error if the value cannot be
 * converted.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid expression.
 * @returns {ToDecimalOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/toDecimal/|MongoDB reference}
 * for $toDecimal
 */
const $toDecimal = se('$toDecimal');

type ToDoubleOperator = {
  $toDouble: Expression,
};

/**
 * Converts a value to a double. Throws an error if the value cannot be
 * converted.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid expression.
 * @returns {ToDoubleOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/toDouble/|MongoDB reference}
 * for $toDouble
 */
const $toDouble = se('$toDouble');

type ToIntOperator = {
  $toInt: Expression,
};

/**
 * Converts a value to an integer. Throws an error if the value cannot be
 * converted.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid expression.
 * @returns {ToIntOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/toInt/|MongoDB reference}
 * for $toInt
 */
const $toInt = se('$toInt');
type ToLongOperator = {
  $toLong: Expression,
};

/**
 * Converts a value to a long. Throws an error if the value cannot be converted.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid expression.
 * @returns {ToLongOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/toLong/|MongoDB reference}
 * for $toLong
 */
const $toLong = se('$toLong');

type ToObjectIdOperator = {
  $toObjectId: Expression,
};

/**
 * Converts a value to an ObjectId. Throws an error if the value cannot be
 * converted.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid expression.
 * @returns {ToObjectIdOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/toObjectId/|MongoDB reference}
 * for $toObjectId
 */
const $toObjectId = se('$toObjectId');

type ToStringOperator = {
  $toString: Expression,
};

/**
 * Converts a value to a string. Throws an error if the value cannot be
 * converted.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid expression.
 * @returns {ToStringOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/toString/|MongoDB reference}
 * for $toString
 */
const $toString = se('$toString');

type ToUpperOperator = {
  $toUpper: StringExpression,
};

/**
 * Returns a string converts to uppercase.
 * @category Operators
 * @function
 * @param {StringExpression} expression A string or a valid expression that
 * resolves to a string.
 * @returns {ToUpperOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/toUpper/|MongoDB reference}
 * for $toUpper
 */
const $toUpper = se('$toUpper');

type ToLowerOperator = {
  $toUpper: StringExpression,
};

/**
 * Returns a string converted to lowercase.
 * @category Operators
 * @function
 * @param {StringExpression} expression A string or a valid expression that
 * resolves to a string.
 * @returns {ToLowerOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/toLower/|MongoDB reference}
 * for $toLower
 */
const $toLower = se('$toLower');

type TruncOperator = {
  $trunc: [NumberExpression, NumberExpression],
};

/**
 * Truncates a number to a whole integer or to a specified decimal place.
 * @category Operators
 * @function
 * @param {NumberExpression} expression1 A number of any valid expression that
 * resolves to a number.
 * @param {NumberExpression} expression2 A number of any valid expression that
 * resolves to an integer between -20 and 100. Defaults to 0 if unspecified.
 * @returns {TruncOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/trunc/|MongoDB reference}
 * for $trunc
 */
const $trunc = at('$trunc');

type TsIncrementOperator = {
  $tsIncrement: Expression,
};

/**
 * Returns the incrementing ordinal from a timestamp as a long.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid expression that resolves to a
 * timestamp.
 * @returns {TsIncrementOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/tsIncrement/|MongoDB reference}
 * for $tsIncrement
 */
const $tsIncrement = se('$tsIncrement');

type TsSecondOperator = {
  $tsSecond: Expression,
};

/**
 * Returns the seconds from a timestamp as a long.
 * @category Operators
 * @function
 * @param {Expression} expression Any valid expression that resolves to a
 * timestamp.
 * @returns {TsSecondOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/tsSecond/|MongoDB reference}
 * for $tsSecond
 */
const $tsSecond = se('$tsSecond');

type TypeOperator = {
  $type: Expression,
};

/**
 * Returns a string that specifies the BSON type of the argument.
 * @category Operators
 * @function
 * @param {Expression} Any valid expression.
 * @returns {TypeOperator}
 * @see {@link https://www.mongodb.com/docs/manual/reference/operator/aggregation/type/|MongoDB reference}
 * for $type
 */
const $type = se('$type');

// TODO
const $unsetField = (field: string, input: ObjectExpression) => ({
  $unsetField: { field, input },
});

// TODO
const $year = se('$year');

export = {
  $abs,
  abs: $abs,
  $acos,
  acos: $acos,
  $acosh,
  acosh: $acosh,
  $add,
  add: $add,
  $addSafe,
  addSafe: $addSafe,
  $addFields,
  addFields: $addFields,
  $addToSet,
  addToSet: $addToSet,
  $all,
  all: $all,
  $allElementsTrue,
  allElementsTrue: $allElementsTrue,
  $and,
  and: $and,
  $anyElementTrue,
  anyElementTrue: $anyElementTrue,
  $arrayElemAt,
  arrayElemAt: $arrayElemAt,
  $arrayElemFirst,
  arrayElemFirst: $arrayElemFirst,
  $arrayElemLast,
  arrayElemLast: $arrayElemLast,
  $arrayToObject,
  arrayToObject: $arrayToObject,
  $asin,
  asin: $asin,
  $asinh,
  asinh: $asinh,
  $atan,
  atan: $atan,
  $atanh,
  atanh: $atanh,
  $avg,
  avg: $avg,
  $bucket,
  bucket: $bucket,
  $bucketAuto,
  bucketAuto: $bucketAuto,
  $binarySize,
  binarySize: $binarySize,
  branch,
  $bitAnd,
  bitAnd: $bitAnd,
  $bitNot,
  bitNot: $bitNot,
  $bitOr,
  bitOr: $bitOr,
  $bitXor,
  bitXor: $bitXor,
  $bsonSize,
  bsonSize: $bsonSize,
  case: branch,
  $ceil,
  ceil: $ceil,
  $changeStream,
  changeStream: $changeStream,
  $cmp,
  cmp: $cmp,
  $comment,
  comment: $comment,
  $concat,
  concat: $concat,
  $concatSafe,
  concatSafe: $concatSafe,
  $concatArrays,
  concatArrays: $concatArrays,
  $concatArraysSafe,
  concatArraysSafe: $concatArraysSafe,
  $cond,
  cond: $cond,
  $convert,
  convert: $convert,
  $cos,
  cos: $cos,
  $cosh,
  cosh: $cosh,
  $count,
  count: $count,
  $covariancePop,
  covariancePop: $covariancePop,
  $covarianceSamp,
  covarianceSamp: $covarianceSamp,
  $decrement,
  decrement: $decrement,
  $degreesToRadians,
  degreesToRadians: $degreesToRadians,
  $denseRank,
  denseRank: $denseRank,
  $divide,
  divide: $divide,
  $divideSafe,
  divideSafe: $divideSafe,
  $documentNumber,
  documentNumber: $documentNumber,
  $documents,
  documents: $documents,
  $ensureArray,
  $each,
  each: $each,
  $elemMatch,
  elemMatch: $elemMatch,
  ensureArray: $ensureArray,
  $ensureNumber,
  ensureNumber: $ensureNumber,
  $ensureString,
  ensureString: $ensureString,
  $eq,
  eq: $eq,
  $exists,
  exists: $exists,
  $exp,
  exp: $exp,
  $expr,
  expr: $expr,
  $facet,
  facet: $facet,
  $filter,
  filter: $filter,
  $find,
  find: $find,
  $first,
  first: $first,
  $floor,
  floor: $floor,
  $group,
  group: $group,
  $getField,
  getField: $getField,
  $gt,
  gt: $gt,
  $gte,
  gte: $gte,
  $if,
  if: $if,
  $ifNull,
  ifNull: $ifNull,
  $in,
  in: $in,
  $increment,
  increment: $increment,
  $inSafe,
  inSafe: $inSafe,
  $isArray,
  isArray: $isArray,
  $isNumber,
  isNumber: $isNumber,
  $last,
  last: $last,
  $let,
  let: $let,
  $limit,
  limit: $limit,
  $linearFill,
  linearFill: $linearFill,
  $literal,
  literal: $literal,
  $locf,
  locf: $locf,
  $log,
  log: $log,
  $log10,
  log10: $log10,
  $lookup,
  lookup: $lookup,
  $lt,
  lt: $lt,
  $lte,
  lte: $lte,
  $map,
  map: $map,
  $match,
  match: $match,
  $max,
  max: $max,
  $meta,
  meta: $meta,
  $min,
  min: $min,
  $merge,
  merge: $merge,
  $mergeObjects,
  mergeObjects: $mergeObjects,
  $mod,
  mod: $mod,
  $multiply,
  multiply: $multiply,
  $multiplySafe,
  multiplySafe: $multiplySafe,
  $ne,
  ne: $ne,
  $nin,
  nin: $nin,
  $nor,
  nor: $nor,
  $not,
  not: $not,
  $or,
  or: $or,
  $out,
  out: $out,
  pipeline,
  $pow,
  pow: $pow,
  $objectToArray,
  objectToArray: $objectToArray,
  $radiansToDegrees,
  radiansToDegrees: $radiansToDegrees,
  $project,
  project: $project,
  $push,
  push: $push,
  $rand,
  rand: $rand,
  $rank,
  rank: $rank,
  $redact,
  redact: $redact,
  $reduce,
  reduce: $reduce,
  $replaceRoot,
  replaceRoot: $replaceRoot,
  $replaceWith,
  replaceWith: $replaceWith,
  $round,
  round: $round,
  $roundStandard,
  roundStandard: $roundStandard,
  $sampleRate,
  sampleRate: $sampleRate,
  $set,
  set: $set,
  $setDifference,
  setDifference: $setDifference,
  $setEquals,
  setEquals: $setEquals,
  $setField,
  setField: $setField,
  $setIntersection,
  setIntersection: $setIntersection,
  $setIsSubset,
  setIsSubset: $setIsSubset,
  $setUnion,
  setUnion: $setUnion,
  $sort,
  sort: $sort,
  $sortByCount,
  sortByCount: $sortByCount,
  $split,
  split: $split,
  $strcasecmp,
  strcasecmp: $strcasecmp,
  $substr,
  substr: $substr,
  $substrBytes,
  substrBytes: $substrBytes,
  $substrCP,
  substrCP: $substrCP,
  $subtract,
  subtract: $subtract,
  $subtractSafe,
  subtractSafe: $subtractSafe,
  $size,
  size: $size,
  $slice,
  slice: $slice,
  $sin,
  sin: $sin,
  $sinh,
  sinh: $sinh,
  $skip,
  skip: $skip,
  $sqrt,
  sqrt: $sqrt,
  $strLenBytes,
  strLenBytes: $strLenBytes,
  $strLenCP,
  strLenCP: $strLenCP,
  $sum,
  sum: $sum,
  $switch,
  switch: $switch,
  $tan,
  tan: $tan,
  $tanh,
  tanh: $tanh,
  $toBool,
  toBool: $toBool,
  $toDate,
  toDate: $toDate,
  $toDecimal,
  toDecimal: $toDecimal,
  $toDouble,
  toDouble: $toDouble,
  $toInt,
  toInt: $toInt,
  $toLong,
  toLong: $toLong,
  $toObjectId,
  toObjectId: $toObjectId,
  $toString,
  toString: $toString,
  $toUpper,
  toUpper: $toUpper,
  $toLower,
  toLower: $toLower,
  $tsIncrement,
  tsIncrement: $tsIncrement,
  $tsSecond,
  tsSecond: $tsSecond,
  $trunc,
  trunc: $trunc,
  $type,
  type: $type,
  $unsetField,
  unsetField: $unsetField,
  $unwind,
  unwind: $unwind,
  $year,
  year: $year,

  // Enums
  ChangeStreamFullDocument,
  MergeActionWhenMatched,
  MergeActionWhenNotMatched,
};