/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview A utility for wrapping a JSTD test object so that any test
 * methods are receive a queue that is compatible with JSTD but supports the
 * JsUnit async API of returning a promise in the test method.
 *
 * To convert a JSTD object call convertToAsyncTestObj on it and run with the
 * JsUnit test runner.
 */

goog.provide('goog.testing.JsTdAsyncWrapper');

goog.require('goog.Promise');


/**
 * @param {Function|string} callback
 * @param {number=} opt_delay
 * @param {...*} var_args
 * @return {number}
 * @private
 */
goog.testing.JsTdAsyncWrapper.REAL_SET_TIMEOUT_FN_ = goog.global.setTimeout;


/**
 * Calls a function after a specified timeout. This uses the original setTimeout
 * to be resilient to tests that override it.
 * @param {Function} fn The function to call.
 * @param {number} timeout Timeout time in ms.
 * @private
 */
goog.testing.JsTdAsyncWrapper.REAL_SET_TIMEOUT_ = function(fn, timeout) {
  'use strict';
  // Setting timeout into a variable is necessary to invoke the function in the
  // default global context. Inlining breaks chrome since it requires setTimeout
  // to be called with the global context, and IE8 doesn't support the call
  // method on setTimeout.
  var setTimeoutFn = goog.testing.JsTdAsyncWrapper.REAL_SET_TIMEOUT_FN_;
  setTimeoutFn(fn, timeout);
};


/**
 * Wraps an object's methods by passing in a Queue that is based on the JSTD
 * async API. The queue exposes a promise that resolves when the queue
 * completes. This promise can be used in JsUnit tests.
 *
 * @template T
 * @param {T} original The original JSTD test object. The object should
 *     contain methods such as testXyz or setUp.
 * @return {T} A object that has all test methods wrapped in a fake
 *     testing queue.
 */
goog.testing.JsTdAsyncWrapper.convertToAsyncTestObj = function(original) {
  'use strict';
  // Wraps a call to a test function and passes an instance of a fake queue
  // into the test function.
  var queueWrapperFn = function(fn) {
    'use strict';
    return function() {
      'use strict';
      var self = /** @type {?} */ (this);  // T this is expected
      var queue = new goog.testing.JsTdAsyncWrapper.Queue(self);
      fn.call(self, queue);
      return queue.startExecuting();
    };
  };

  var newTestObj = {};
  for (var prop in original) {
    // If this is a test or tearDown/setUp method wrap the method with a queue
    if (prop.indexOf('test') == 0 || prop == 'setUp' || prop == 'tearDown') {
      newTestObj[prop] = queueWrapperFn(original[prop]);
    } else {
      newTestObj[prop] = original[prop];
    }
  }
  return newTestObj;
};



/**
 * A queue that mirrors the JSTD Async Queue api but exposes a promise that
 * resolves once the queue is complete for compatibility with JsUnit.
 * @param {!Object} testObj The test object containing all test methods. This
 *     object is passed into queue callbacks as the "this" object.
 * @constructor
 * @final
 */
goog.testing.JsTdAsyncWrapper.Queue = function(testObj) {
  'use strict';
  /**
   * The queue steps.
   * @private {!Array<!goog.testing.JsTdAsyncWrapper.Step_>}
   */
  this.steps_ = [];

  /**
   * A delegate that is used within a defer call.
   * @private {?goog.testing.JsTdAsyncWrapper.Queue}
   */
  this.delegate_ = null;

  /**
   * thisArg that should be used by default for addCallback function calls.
   * @private {!Object}
   */
  this.testObj_ = testObj;
};


/**
 * @param {string|function(!goog.testing.JsTdAsyncWrapper.Pool_=)} stepName
 *     The name of the current testing step, or the fn parameter if
 *     no stepName is desired.
 * @param {function(!goog.testing.JsTdAsyncWrapper.Pool_=)=} opt_fn A function
 *   that will be called.
 */
goog.testing.JsTdAsyncWrapper.Queue.prototype.defer = function(
    stepName, opt_fn) {
  'use strict';
  var fn = opt_fn;
  if (!opt_fn && typeof stepName == 'function') {
    fn = stepName;
    stepName = '(Not named)';
  }
  // If another queue.defer is called within a pool callback it should be
  // executed after the current one. Any defer that is called within a defer
  // will be passed to a delegate and the current defer waits till all delegate
  // defer are resolved.
  if (this.delegate_) {
    this.delegate_.defer(stepName, fn);
    return;
  }
  this.steps_.push(new goog.testing.JsTdAsyncWrapper.Step_(
      /** @type {string} */ (stepName),
      /** @type {function(!goog.testing.JsTdAsyncWrapper.Pool_=)} */ (fn)));
};


/**
 * Starts the execution.
 * @return {!goog.Promise<void>}
 */
goog.testing.JsTdAsyncWrapper.Queue.prototype.startExecuting = function() {
  'use strict';
  return new goog.Promise(goog.bind(function(resolve, reject) {
    'use strict';
    this.executeNextStep_(resolve, reject);
  }, this));
};


/**
 * Executes the next step on the queue waiting for all pool callbacks and then
 * starts executing any delegate queues before it finishes.
 * @param {function()} callback
 * @param {function(*)} errback
 * @private
 */
goog.testing.JsTdAsyncWrapper.Queue.prototype.executeNextStep_ = function(
    callback, errback) {
  'use strict';
  // Note: From this point on, we can no longer use goog.Promise (which uses
  // the goog.async.run queue) because it conflicts with MockClock, and we can't
  // use the native Promise because it is not supported on IE. So we revert to
  // using callbacks and setTimeout.
  if (!this.steps_.length) {
    callback();
    return;
  }
  var step = this.steps_.shift();
  this.delegate_ = new goog.testing.JsTdAsyncWrapper.Queue(this.testObj_);
  var pool = new goog.testing.JsTdAsyncWrapper.Pool_(
      this.testObj_, goog.bind(function() {
        'use strict';
        goog.testing.JsTdAsyncWrapper.REAL_SET_TIMEOUT_(goog.bind(function() {
          'use strict';
          this.executeDelegate_(callback, errback);
        }, this), 0);
      }, this), goog.bind(function(reason) {
        'use strict';
        this.handleError_(errback, reason, step.name);
      }, this));
  try {
    step.fn.call(this.testObj_, pool);
  } catch (e) {
    this.handleError_(errback, e, step.name);
  }
  pool.maybeComplete();
};


/**
 * Execute the delegate queue.
 * @param {function()} callback
 * @param {function(*)} errback
 * @private
 */
goog.testing.JsTdAsyncWrapper.Queue.prototype.executeDelegate_ = function(
    callback, errback) {
  'use strict';
  // Wait till the delegate queue completes before moving on to the
  // next step.
  if (!this.delegate_) {
    this.executeNextStep_(callback, errback);
    return;
  }
  this.delegate_.executeNextStep_(goog.bind(function() {
    'use strict';
    this.delegate_ = null;
    goog.testing.JsTdAsyncWrapper.REAL_SET_TIMEOUT_(goog.bind(function() {
      'use strict';
      this.executeNextStep_(callback, errback);
    }, this), 0);
  }, this), errback);
};


/**
 * @param {function(*)} errback
 * @param {*} reason
 * @param {string} stepName
 * @private
 */
goog.testing.JsTdAsyncWrapper.Queue.prototype.handleError_ = function(
    errback, reason, stepName) {
  'use strict';
  var error = reason instanceof Error ? reason : Error(reason);
  error.message = 'In step ' + stepName + ', error: ' + error.message;
  errback(reason);
};



/**
 * A step to be executed.
 * @param {string} name
 * @param {function(!goog.testing.JsTdAsyncWrapper.Pool_=)} fn
 * @constructor
 * @private
 */
goog.testing.JsTdAsyncWrapper.Step_ = function(name, fn) {
  'use strict';
  /** @final {string} */
  this.name = name;
  /** @final {function(!goog.testing.JsTdAsyncWrapper.Pool_=)} */
  this.fn = fn;
};



/**
 * A fake pool that mimics the JSTD AsyncTestCase's pool object.
 * @param {!Object} testObj The test object containing all test methods. This
 *     object is passed into queue callbacks as the "this" object.
 * @param {function()} callback
 * @param {function(*)} errback
 * @constructor
 * @private
 * @final
 */
goog.testing.JsTdAsyncWrapper.Pool_ = function(testObj, callback, errback) {
  'use strict';
  /** @private {number} */
  this.outstandingCallbacks_ = 0;

  /** @private {function()} */
  this.callback_ = callback;

  /** @private {function(*)} */
  this.errback_ = errback;

  /**
   * thisArg that should be used by default for defer function calls.
   * @private {!Object}
   */
  this.testObj_ = testObj;

  /** @private {boolean} */
  this.callbackCalled_ = false;
};


/**
 * @return {function()}
 */
goog.testing.JsTdAsyncWrapper.Pool_.prototype.noop = function() {
  'use strict';
  return this.addCallback(function() {});
};


/**
 * @param {function(...*):*} fn The function to add to the pool.
 * @param {?number=} opt_n The number of permitted uses of the given callback;
 *     defaults to one.
 * @param {?number=} opt_timeout The timeout in milliseconds.
 *     This is not supported in the adapter for now. Specifying this argument
 *     will result in a test failure.
 * @param {?string=} opt_description The callback description.
 * @return {function()}
 */
goog.testing.JsTdAsyncWrapper.Pool_.prototype.addCallback = function(
    fn, opt_n, opt_timeout, opt_description) {
  'use strict';
  // TODO(mtragut): This could be fixed if required by test cases.
  if (opt_timeout || opt_description) {
    throw new Error(
        'Setting timeout or description in a pool callback is not supported.');
  }
  var numCallbacks = opt_n || 1;
  this.outstandingCallbacks_ = this.outstandingCallbacks_ + numCallbacks;
  return goog.bind(function() {
    'use strict';
    try {
      fn.apply(this.testObj_, arguments);
    } catch (e) {
      if (opt_description) {
        e.message = opt_description + e.message;
      }
      this.errback_(e);
    }
    this.outstandingCallbacks_ = this.outstandingCallbacks_ - 1;
    this.maybeComplete();
  }, this);
};


/**
 * @param {function(...*):*} fn The function to add to the pool.
 * @param {?number=} opt_n The number of permitted uses of the given callback;
 *     defaults to one.
 * @param {?number=} opt_timeout The timeout in milliseconds.
 *     This is not supported in the adapter for now. Specifying this argument
 *     will result in a test failure.
 * @param {?string=} opt_description The callback description.
 * @return {function()}
 */
goog.testing.JsTdAsyncWrapper.Pool_.prototype.add =
    goog.testing.JsTdAsyncWrapper.Pool_.prototype.addCallback;


/**
 * @param {string} msg The message to print if the error callback gets called.
 * @return {function()}
 */
goog.testing.JsTdAsyncWrapper.Pool_.prototype.addErrback = function(msg) {
  'use strict';
  return goog.bind(function() {
    'use strict';
    var errorMsg = msg;
    if (arguments.length) {
      errorMsg += ' - Error callback called with params: ( ';
      for (var i = 0; i < arguments.length; i++) {
        var arg = arguments[i];
        errorMsg += arg + ' ';
        if (arg instanceof Error) {
          errorMsg += '\n' + arg.stack + '\n';
        }
      }
      errorMsg += ')';
    }
    this.errback_(errorMsg);
  }, this);
};


/**
 * Completes the pool if there are no outstanding callbacks.
 */
goog.testing.JsTdAsyncWrapper.Pool_.prototype.maybeComplete = function() {
  'use strict';
  if (this.outstandingCallbacks_ == 0 && !this.callbackCalled_) {
    this.callbackCalled_ = true;
    this.callback_();
  }
};
