// 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) {
  return Object.prototype.toString.call(value) === '[object Promise]'
}

/**
 * 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) {
      const value = arr[i]
      const 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,
}
