// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

/**
 * @fileoverview Defines a handful of utility functions to simplify working
 * with promises.
 */

'use strict';



/**
 * Determines whether a {@code value} should be treated as a promise.
 * Any object whose "then" property is a function will be considered a promise.
 *
 * @param {?} value The value to test.
 * @return {boolean} Whether the value is a promise.
 */
function isPromise(value) {
  try {
    // Use array notation so the Closure compiler does not obfuscate away our
    // contract.
    return value
        && (typeof value === 'object' || typeof value === 'function')
        && typeof value['then'] === 'function';
  } catch (ex) {
    return false;
  }
}


/**
 * Creates a promise that will be resolved at a set time in the future.
 * @param {number} ms The amount of time, in milliseconds, to wait before
 *     resolving the promise.
 * @return {!Promise<void>} The promise.
 */
function delayed(ms) {
  return new Promise(resolve => {
    setTimeout(() => resolve(), ms);
  });
}


/**
 * Wraps a function that expects a node-style callback as its final
 * argument. This callback expects two arguments: an error value (which will be
 * null if the call succeeded), and the success value as the second argument.
 * The callback will the resolve or reject the returned promise, based on its
 * arguments.
 * @param {!Function} fn The function to wrap.
 * @param {...?} args The arguments to apply to the function, excluding the
 *     final callback.
 * @return {!Thenable} A promise that will be resolved with the
 *     result of the provided function's callback.
 */
function checkedNodeCall(fn, ...args) {
  return new Promise(function(fulfill, reject) {
    try {
      fn(...args, function(error, value) {
        error ? reject(error) : fulfill(value);
      });
    } catch (ex) {
      reject(ex);
    }
  });
}

/**
 * Registers a listener to invoke when a promise is resolved, regardless
 * of whether the promise's value was successfully computed. This function
 * is synonymous with the {@code finally} clause in a synchronous API:
 *
 *     // Synchronous API:
 *     try {
 *       doSynchronousWork();
 *     } finally {
 *       cleanUp();
 *     }
 *
 *     // Asynchronous promise API:
 *     doAsynchronousWork().finally(cleanUp);
 *
 * __Note:__ similar to the {@code finally} clause, if the registered
 * callback returns a rejected promise or throws an error, it will silently
 * replace the rejection error (if any) from this promise:
 *
 *     try {
 *       throw Error('one');
 *     } finally {
 *       throw Error('two');  // Hides Error: one
 *     }
 *
 *     let p = Promise.reject(Error('one'));
 *     promise.finally(p, function() {
 *       throw Error('two');  // Hides Error: one
 *     });
 *
 * @param {!IThenable<?>} promise The promise to add the listener to.
 * @param {function(): (R|IThenable<R>)} callback The function to call when
 *     the promise is resolved.
 * @return {!Promise<R>} A promise that will be resolved with the callback
 *     result.
 * @template R
 */
async function thenFinally(promise, callback) {
  try {
    await Promise.resolve(promise);
    return callback();
  } catch (e) {
    await callback();
    throw e;
  }
}


/**
 * Calls a function for each element in an array and inserts the result into a
 * new array, which is used as the fulfillment value of the promise returned
 * by this function.
 *
 * If the return value of the mapping function is a promise, this function
 * will wait for it to be fulfilled before inserting it into the new array.
 *
 * If the mapping function throws or returns a rejected promise, the
 * promise returned by this function will be rejected with the same reason.
 * Only the first failure will be reported; all subsequent errors will be
 * silently ignored.
 *
 * @param {!(Array<TYPE>|IThenable<!Array<TYPE>>)} array The array to iterate
 *     over, or a promise that will resolve to said array.
 * @param {function(this: SELF, TYPE, number, !Array<TYPE>): ?} fn The
 *     function to call for each element in the array. This function should
 *     expect three arguments (the element, the index, and the array itself.
 * @param {SELF=} self The object to be used as the value of 'this' within `fn`.
 * @template TYPE, SELF
 */
async function map(array, fn, self = undefined) {
  const v = await Promise.resolve(array);
  if (!Array.isArray(v)) {
    throw TypeError('not an array');
  }

  const arr = /** @type {!Array} */(v);
  const n = arr.length;
  const values = new Array(n);

  for (let i = 0; i < n; i++) {
    if (i in arr) {
      values[i] = await Promise.resolve(fn.call(self, arr[i], i, arr));
    }
  }
  return values;
}


/**
 * Calls a function for each element in an array, and if the function returns
 * true adds the element to a new array.
 *
 * If the return value of the filter function is a promise, this function
 * will wait for it to be fulfilled before determining whether to insert the
 * element into the new array.
 *
 * If the filter function throws or returns a rejected promise, the promise
 * returned by this function will be rejected with the same reason. Only the
 * first failure will be reported; all subsequent errors will be silently
 * ignored.
 *
 * @param {!(Array<TYPE>|IThenable<!Array<TYPE>>)} array The array to iterate
 *     over, or a promise that will resolve to said array.
 * @param {function(this: SELF, TYPE, number, !Array<TYPE>): (
 *             boolean|IThenable<boolean>)} fn The function
 *     to call for each element in the array.
 * @param {SELF=} self The object to be used as the value of 'this' within `fn`.
 * @template TYPE, SELF
 */
async function filter(array, fn, self = undefined) {
  const v = await Promise.resolve(array);
  if (!Array.isArray(v)) {
    throw TypeError('not an array');
  }

  const arr = /** @type {!Array} */(v);
  const n = arr.length;
  const values = [];
  let valuesLength = 0;

  for (let i = 0; i < n; i++) {
    if (i in arr) {
      let value = arr[i];
      let include = await fn.call(self, value, i, arr);
      if (include) {
        values[valuesLength++] = value;
      }
    }
  }
  return values;
}


/**
 * Returns a promise that will be resolved with the input value in a
 * fully-resolved state. If the value is an array, each element will be fully
 * resolved. Likewise, if the value is an object, all keys will be fully
 * resolved. In both cases, all nested arrays and objects will also be
 * fully resolved.  All fields are resolved in place; the returned promise will
 * resolve on {@code value} and not a copy.
 *
 * Warning: This function makes no checks against objects that contain
 * cyclical references:
 *
 *     var value = {};
 *     value['self'] = value;
 *     promise.fullyResolved(value);  // Stack overflow.
 *
 * @param {*} value The value to fully resolve.
 * @return {!Thenable} A promise for a fully resolved version
 *     of the input value.
 */
async function fullyResolved(value) {
  value = await Promise.resolve(value);
  if (Array.isArray(value)) {
    return fullyResolveKeys(/** @type {!Array} */ (value));
  }

  if (value && typeof value === 'object') {
    return fullyResolveKeys(/** @type {!Object} */ (value));
  }

  if (typeof value === 'function') {
    return fullyResolveKeys(/** @type {!Object} */ (value));
  }

  return value;
}


/**
 * @param {!(Array|Object)} obj the object to resolve.
 * @return {!Thenable} A promise that will be resolved with the
 *     input object once all of its values have been fully resolved.
 */
async function fullyResolveKeys(obj) {
  const isArray = Array.isArray(obj);
  const numKeys = isArray ? obj.length : Object.keys(obj).length;

  if (!numKeys) {
    return obj;
  }

  async function forEachProperty(obj, fn) {
    for (let key in obj) {
      await fn(obj[key], key);
    }
  }

  async function forEachElement(arr, fn) {
    for (let i = 0; i < arr.length; i++) {
      await fn(arr[i], i);
    }
  }

  const forEachKey = isArray ? forEachElement : forEachProperty;
  await forEachKey(obj, async function(partialValue, key) {
    if (!Array.isArray(partialValue)
        && (!partialValue || typeof partialValue !== 'object')) {
      return;
    }
    let resolvedValue = await fullyResolved(partialValue);
    obj[key] = resolvedValue;
  });
  return obj;
}


// PUBLIC API


module.exports = {
  checkedNodeCall,
  delayed,
  filter,
  finally: thenFinally,
  fullyResolved,
  isPromise,
  map
};
