import * as checkErrorDefault from 'check-error';

let checkError = checkErrorDefault;

export default function (chai, utils) {
  const Assertion = chai.Assertion;
  const assert = chai.assert;
  const proxify = utils.proxify;

  // If we are using a version of Chai that has checkError on it,
  // we want to use that version to be consistent. Otherwise, we use
  // what was passed to the factory.
  if (utils.checkError) {
    checkError = utils.checkError;
  }

  function isLegacyJQueryPromise(thenable) {
    // jQuery promises are Promises/A+-compatible since 3.0.0. jQuery 3.0.0 is also the first version
    // to define the catch method.
    return (
      typeof thenable.catch !== 'function' &&
      typeof thenable.always === 'function' &&
      typeof thenable.done === 'function' &&
      typeof thenable.fail === 'function' &&
      typeof thenable.pipe === 'function' &&
      typeof thenable.progress === 'function' &&
      typeof thenable.state === 'function'
    );
  }

  function assertIsAboutPromise(assertion) {
    if (typeof assertion._obj.then !== 'function') {
      throw new TypeError(
        utils.inspect(assertion._obj) + ' is not a thenable.'
      );
    }
    if (isLegacyJQueryPromise(assertion._obj)) {
      throw new TypeError(
        'Chai as Promised is incompatible with thenables of jQuery<3.0.0, sorry! Please ' +
          'upgrade jQuery or use another Promises/A+ compatible library (see ' +
          'http://promisesaplus.com/).'
      );
    }
  }

  function proxifyIfSupported(assertion) {
    return proxify === undefined ? assertion : proxify(assertion);
  }

  function method(name, asserter) {
    utils.addMethod(Assertion.prototype, name, function () {
      assertIsAboutPromise(this);
      return asserter.apply(this, arguments);
    });
  }

  function property(name, asserter) {
    utils.addProperty(Assertion.prototype, name, function () {
      assertIsAboutPromise(this);
      return proxifyIfSupported(asserter.apply(this, arguments));
    });
  }

  function doNotify(promise, done) {
    promise.then(() => done(), done);
  }

  // These are for clarity and to bypass Chai refusing to allow `undefined` as actual when used with `assert`.
  function assertIfNegated(assertion, message, extra) {
    assertion.assert(true, null, message, extra.expected, extra.actual);
  }

  function assertIfNotNegated(assertion, message, extra) {
    assertion.assert(false, message, null, extra.expected, extra.actual);
  }

  function getBasePromise(assertion) {
    // We need to chain subsequent asserters on top of ones in the chain already (consider
    // `eventually.have.property("foo").that.equals("bar")`), only running them after the existing ones pass.
    // So the first base-promise is `assertion._obj`, but after that we use the assertions themselves, i.e.
    // previously derived promises, to chain off of.
    return typeof assertion.then === 'function' ? assertion : assertion._obj;
  }

  function getReasonName(reason) {
    return reason instanceof Error
      ? reason.toString()
      : checkError.getConstructorName(reason);
  }

  // Grab these first, before we modify `Assertion.prototype`.

  const propertyNames = Object.getOwnPropertyNames(Assertion.prototype);

  const propertyDescs = {};
  for (const name of propertyNames) {
    propertyDescs[name] = Object.getOwnPropertyDescriptor(
      Assertion.prototype,
      name
    );
  }

  property('fulfilled', function () {
    const derivedPromise = getBasePromise(this).then(
      (value) => {
        assertIfNegated(
          this,
          'expected promise not to be fulfilled but it was fulfilled with #{act}',
          {actual: value}
        );
        return value;
      },
      (reason) => {
        assertIfNotNegated(
          this,
          'expected promise to be fulfilled but it was rejected with #{act}',
          {actual: getReasonName(reason)}
        );
        return reason;
      }
    );

    transferPromiseness(this, derivedPromise);
    return this;
  });

  property('rejected', function () {
    const derivedPromise = getBasePromise(this).then(
      (value) => {
        assertIfNotNegated(
          this,
          'expected promise to be rejected but it was fulfilled with #{act}',
          {actual: value}
        );
        return value;
      },
      (reason) => {
        assertIfNegated(
          this,
          'expected promise not to be rejected but it was rejected with #{act}',
          {actual: getReasonName(reason)}
        );

        // Return the reason, transforming this into a fulfillment, to allow further assertions, e.g.
        // `promise.should.be.rejected.and.eventually.equal("reason")`.
        return reason;
      }
    );

    transferPromiseness(this, derivedPromise);
    return this;
  });

  method('rejectedWith', function (errorLike, errMsgMatcher, message) {
    let errorLikeName = null;
    const negate = utils.flag(this, 'negate') || false;

    // rejectedWith with that is called without arguments is
    // the same as a plain ".rejected" use.
    if (
      errorLike === undefined &&
      errMsgMatcher === undefined &&
      message === undefined
    ) {
      /* eslint-disable no-unused-expressions */
      return this.rejected;
      /* eslint-enable no-unused-expressions */
    }

    if (message !== undefined) {
      utils.flag(this, 'message', message);
    }

    if (errorLike instanceof RegExp || typeof errorLike === 'string') {
      errMsgMatcher = errorLike;
      errorLike = null;
    } else if (errorLike && errorLike instanceof Error) {
      errorLikeName = errorLike.toString();
    } else if (typeof errorLike === 'function') {
      errorLikeName = checkError.getConstructorName(errorLike);
    } else {
      errorLike = null;
    }
    const everyArgIsDefined = Boolean(errorLike && errMsgMatcher);

    let matcherRelation = 'including';
    if (errMsgMatcher instanceof RegExp) {
      matcherRelation = 'matching';
    }

    const derivedPromise = getBasePromise(this).then(
      (value) => {
        let assertionMessage = null;
        let expected = null;

        if (errorLike) {
          assertionMessage =
            'expected promise to be rejected with #{exp} but it was fulfilled with #{act}';
          expected = errorLikeName;
        } else if (errMsgMatcher) {
          assertionMessage =
            `expected promise to be rejected with an error ${matcherRelation} #{exp} but ` +
            `it was fulfilled with #{act}`;
          expected = errMsgMatcher;
        }

        assertIfNotNegated(this, assertionMessage, {expected, actual: value});
        return value;
      },
      (reason) => {
        const errorLikeCompatible =
          errorLike &&
          (errorLike instanceof Error
            ? checkError.compatibleInstance(reason, errorLike)
            : checkError.compatibleConstructor(reason, errorLike));

        const errMsgMatcherCompatible =
          errMsgMatcher === reason ||
          (errMsgMatcher &&
            reason &&
            checkError.compatibleMessage(reason, errMsgMatcher));

        const reasonName = getReasonName(reason);

        if (negate && everyArgIsDefined) {
          if (errorLikeCompatible && errMsgMatcherCompatible) {
            this.assert(
              true,
              null,
              'expected promise not to be rejected with #{exp} but it was rejected ' +
                'with #{act}',
              errorLikeName,
              reasonName
            );
          }
        } else {
          if (errorLike) {
            this.assert(
              errorLikeCompatible,
              'expected promise to be rejected with #{exp} but it was rejected with #{act}',
              'expected promise not to be rejected with #{exp} but it was rejected ' +
                'with #{act}',
              errorLikeName,
              reasonName
            );
          }

          if (errMsgMatcher) {
            this.assert(
              errMsgMatcherCompatible,
              `expected promise to be rejected with an error ${matcherRelation} #{exp} but got ` +
                `#{act}`,
              `expected promise not to be rejected with an error ${matcherRelation} #{exp}`,
              errMsgMatcher,
              checkError.getMessage(reason)
            );
          }
        }

        return reason;
      }
    );

    transferPromiseness(this, derivedPromise);
    return this;
  });

  property('eventually', function () {
    utils.flag(this, 'eventually', true);
    return this;
  });

  method('notify', function (done) {
    doNotify(getBasePromise(this), done);
    return this;
  });

  method('become', function (value, message) {
    return this.eventually.deep.equal(value, message);
  });

  // ### `eventually`

  // We need to be careful not to trigger any getters, thus `Object.getOwnPropertyDescriptor` usage.
  const methodNames = propertyNames.filter((name) => {
    return name !== 'assert' && typeof propertyDescs[name].value === 'function';
  });

  methodNames.forEach((methodName) => {
    Assertion.overwriteMethod(
      methodName,
      (originalMethod) =>
        function () {
          return doAsserterAsyncAndAddThen(originalMethod, this, arguments);
        }
    );
  });

  const getterNames = propertyNames.filter((name) => {
    return name !== '_obj' && typeof propertyDescs[name].get === 'function';
  });

  getterNames.forEach((getterName) => {
    // Chainable methods are things like `an`, which can work both for `.should.be.an.instanceOf` and as
    // `should.be.an("object")`. We need to handle those specially.
    const isChainableMethod = Object.prototype.hasOwnProperty.call(
      Assertion.prototype.__methods,
      getterName
    );

    if (isChainableMethod) {
      Assertion.overwriteChainableMethod(
        getterName,
        (originalMethod) =>
          function () {
            return doAsserterAsyncAndAddThen(originalMethod, this, arguments);
          },
        (originalGetter) =>
          function () {
            return doAsserterAsyncAndAddThen(originalGetter, this);
          }
      );
    } else {
      Assertion.overwriteProperty(
        getterName,
        (originalGetter) =>
          function () {
            return proxifyIfSupported(
              doAsserterAsyncAndAddThen(originalGetter, this)
            );
          }
      );
    }
  });

  function doAsserterAsyncAndAddThen(asserter, assertion, args) {
    // Since we're intercepting all methods/properties, we need to just pass through if they don't want
    // `eventually`, or if we've already fulfilled the promise (see below).
    if (!utils.flag(assertion, 'eventually')) {
      asserter.apply(assertion, args);
      return assertion;
    }

    const derivedPromise = getBasePromise(assertion)
      .then((value) => {
        // Set up the environment for the asserter to actually run: `_obj` should be the fulfillment value, and
        // now that we have the value, we're no longer in "eventually" mode, so we won't run any of this code,
        // just the base Chai code that we get to via the short-circuit above.
        assertion._obj = value;
        utils.flag(assertion, 'eventually', false);

        return args ? transformAsserterArgs(args) : args;
      })
      .then((newArgs) => {
        asserter.apply(assertion, newArgs);

        // Because asserters, for example `property`, can change the value of `_obj` (i.e. change the "object"
        // flag), we need to communicate this value change to subsequent chained asserters. Since we build a
        // promise chain paralleling the asserter chain, we can use it to communicate such changes.
        return assertion._obj;
      });

    transferPromiseness(assertion, derivedPromise);
    return assertion;
  }

  // ### Now use the `Assertion` framework to build an `assert` interface.
  const originalAssertMethods = Object.getOwnPropertyNames(assert).filter(
    (propName) => {
      return typeof assert[propName] === 'function';
    }
  );

  assert.isFulfilled = (promise, message) =>
    new Assertion(promise, message).to.be.fulfilled;

  assert.isRejected = (promise, errorLike, errMsgMatcher, message) => {
    const assertion = new Assertion(promise, message);
    return assertion.to.be.rejectedWith(errorLike, errMsgMatcher, message);
  };

  assert.becomes = (promise, value, message) =>
    assert.eventually.deepEqual(promise, value, message);

  assert.doesNotBecome = (promise, value, message) =>
    assert.eventually.notDeepEqual(promise, value, message);

  assert.eventually = {};
  originalAssertMethods.forEach((assertMethodName) => {
    assert.eventually[assertMethodName] = function (promise) {
      const otherArgs = Array.prototype.slice.call(arguments, 1);

      let customRejectionHandler;
      const message = arguments[assert[assertMethodName].length - 1];
      if (typeof message === 'string') {
        customRejectionHandler = (reason) => {
          throw new chai.AssertionError(
            `${message}\n\nOriginal reason: ${utils.inspect(reason)}`
          );
        };
      }

      const returnedPromise = promise.then(
        (fulfillmentValue) =>
          assert[assertMethodName].apply(
            assert,
            [fulfillmentValue].concat(otherArgs)
          ),
        customRejectionHandler
      );

      returnedPromise.notify = (done) => {
        doNotify(returnedPromise, done);
      };

      return returnedPromise;
    };
  });
}

function defaultTransferPromiseness(assertion, promise) {
  assertion.then = promise.then.bind(promise);
}

function defaultTransformAsserterArgs(values) {
  return values;
}

let customTransferPromiseness;
let customTransformAsserterArgs;

export function setTransferPromiseness(fn) {
  customTransferPromiseness = fn || defaultTransferPromiseness;
}

export function setTransformAsserterArgs(fn) {
  customTransformAsserterArgs = fn || defaultTransformAsserterArgs;
}

export function transferPromiseness(assertion, promise) {
  if (customTransferPromiseness) {
    customTransferPromiseness(assertion, promise);
  } else {
    defaultTransferPromiseness(assertion, promise);
  }
}

export function transformAsserterArgs(values) {
  if (customTransformAsserterArgs) {
    return customTransformAsserterArgs(values);
  }
  return defaultTransformAsserterArgs(values);
}