const _ = require('lodash')
const Promise = require('bluebird')

const $dom = require('../../dom')
const $utils = require('../../cypress/utils')
const $errUtils = require('../../cypress/error_utils')

const returnFalseIfThenable = (key, ...args) => {
  if ((key === 'then') && _.isFunction(args[0]) && _.isFunction(args[1])) {
    // https://github.com/cypress-io/cypress/issues/111
    // if we're inside of a promise then the promise lib will naturally
    // pass (at least) two functions to another cy.then
    // this works similar to the way mocha handles thenables. for instance
    // in coffeescript when we pass cypress commands within a Promise's
    // .then() because the value is the cypress instance means that
    // the Promise lib will attach a new .then internally. it would never
    // resolve unless we invoked it immediately, so we invoke it and
    // return false then ensuring the command is not queued
    args[0]()

    return false
  }
}

const primitiveToObject = (memo) => {
  if (_.isString(memo)) {
    return new String(memo)
  }

  if (_.isNumber(memo)) {
    return new Number(memo)
  }

  return memo
}

const getFormattedElement = ($el) => {
  if ($dom.isElement($el)) {
    return $dom.getElements($el)
  }

  return $el
}

module.exports = function (Commands, Cypress, cy, state) {
  // thens can return more "thenables" which are not resolved
  // until they're 'really' resolved, so naturally this API
  // supports nesting promises
  const thenFn = function (subject, userOptions, fn) {
    const ctx = state('ctx')

    if (_.isFunction(userOptions)) {
      fn = userOptions
      userOptions = {}
    }

    const options = _.defaults({}, userOptions, {
      timeout: Cypress.config('defaultCommandTimeout'),
    })

    // clear the timeout since we are handling
    // it ourselves
    cy.clearTimeout()

    // TODO: use subject from state("subject")

    const remoteSubject = cy.getRemotejQueryInstance(subject)

    let hasSpreadArray

    try {
      hasSpreadArray = subject && subject._spreadArray
    } catch (error) {} // eslint-disable-line no-empty

    let args = remoteSubject || subject

    args = hasSpreadArray ? args : [args]

    // name could be invoke or its!
    const name = state('current').get('name')

    const cleanup = () => {
      state('onInjectCommand', undefined)
      cy.removeListener('command:enqueued', enqueuedCommand)

      return null
    }

    let invokedCyCommand = false

    const enqueuedCommand = () => {
      invokedCyCommand = true

      return invokedCyCommand
    }

    state('onInjectCommand', returnFalseIfThenable)

    cy.once('command:enqueued', enqueuedCommand)

    // this code helps juggle subjects forward
    // the same way that promises work
    const current = state('current')
    const next = current.get('next')

    // TODO: this code may no longer be necessary
    // if the next command is chained to us then when it eventually
    // runs we need to reset the subject to be the return value of the
    // previous command so the subject is continuously juggled forward
    if (next && next.get('chainerId') === current.get('chainerId')) {
      const checkSubject = (newSubject, args) => {
        if (state('current') !== next) {
          return
        }

        // get whatever the previous commands return
        // value is. this likely does not match the 'var current'
        // command in the case of nested cy commands
        const s = next.get('prev').get('subject')

        // find the new subject and splice it out
        // with our existing subject
        const index = _.indexOf(args, newSubject)

        if (index > -1) {
          args.splice(index, 1, s)
        }

        return cy.removeListener('next:subject:prepared', checkSubject)
      }

      cy.on('next:subject:prepared', checkSubject)
    }

    const getRet = () => {
      let ret = fn.apply(ctx, args)

      if (cy.isCy(ret)) {
        ret = undefined
      }

      if (ret && invokedCyCommand && !ret.then) {
        $errUtils.throwErrByPath('then.callback_mixes_sync_and_async', {
          onFail: options._log,
          args: { value: $utils.stringify(ret) },
        })
      }

      return ret
    }

    return Promise
    .try(getRet)
    .timeout(options.timeout)
    .then((ret) => {
      // if ret is undefined then
      // resolve with the existing subject
      if (_.isUndefined(ret)) {
        return subject
      }

      return ret
    }).catch(Promise.TimeoutError, () => {
      return $errUtils.throwErrByPath('invoke_its.timed_out', {
        onFail: options._log,
        args: {
          cmd: name,
          timeout: options.timeout,
          func: fn.toString(),
        },
      })
    })
    .finally(cleanup)
  }

  const invokeItsFn = (subject, str, userOptions, ...args) => {
    return invokeBaseFn(userOptions || { log: true }, subject, str, ...args)
  }

  const invokeFn = (subject, userOptionsOrStr, ...args) => {
    const userOptionsPassed = _.isObject(userOptionsOrStr) && !_.isFunction(userOptionsOrStr)
    let userOptions = null
    let str = null

    if (!userOptionsPassed) {
      str = userOptionsOrStr
      userOptions = { log: true }
    } else {
      userOptions = userOptionsOrStr
      if (args.length > 0) {
        str = args[0]
        args = args.slice(1)
      }
    }

    return invokeBaseFn(userOptions, subject, str, ...args)
  }

  const invokeBaseFn = (userOptions, subject, str, ...args) => {
    const name = state('current').get('name')

    const isCmdIts = name === 'its'
    const isCmdInvoke = name === 'invoke'

    const getMessage = () => {
      if (isCmdIts) {
        return `.${str}`
      }

      return `.${str}(${$utils.stringify(args)})`
    }

    // to allow the falsy value 0 to be used
    const isProp = (str) => {
      return !!str || (str === 0)
    }

    const message = getMessage()

    let traversalErr = null

    // copy userOptions because _log is added below.
    const options = _.extend({}, userOptions)

    if (options.log) {
      options._log = Cypress.log({
        message,
        $el: $dom.isElement(subject) ? subject : null,
        timeout: options.timeout,
        consoleProps () {
          return { Subject: subject }
        },
      })
    }

    // check for false positive (negative?) with 0 given as index
    if (!isProp(str)) {
      $errUtils.throwErrByPath('invoke_its.null_or_undefined_property_name', {
        onFail: options._log,
        args: { cmd: name, identifier: isCmdIts ? 'property' : 'function' },
      })
    }

    if (!_.isString(str) && !_.isNumber(str)) {
      $errUtils.throwErrByPath('invoke_its.invalid_prop_name_arg', {
        onFail: options._log,
        args: { cmd: name, identifier: isCmdIts ? 'property' : 'function' },
      })
    }

    if (!_.isObject(userOptions) || _.isFunction(userOptions)) {
      $errUtils.throwErrByPath('invoke_its.invalid_options_arg', {
        onFail: options._log,
        args: { cmd: name },
      })
    }

    if (isCmdIts && args && args.length > 0) {
      $errUtils.throwErrByPath('invoke_its.invalid_num_of_args', {
        onFail: options._log,
        args: { cmd: name },
      })
    }

    const propertyNotOnSubjectErr = (prop) => {
      return $errUtils.cypressErrByPath('invoke_its.nonexistent_prop', {
        args: {
          prop,
          cmd: name,
        },
      })
    }

    const propertyValueNullOrUndefinedErr = (prop, value) => {
      const errMessagePath = isCmdIts ? 'its' : 'invoke'

      return $errUtils.cypressErrByPath(`${errMessagePath}.null_or_undefined_prop_value`, {
        args: {
          prop,
          value,
        },
        cmd: name,
      })
    }

    const subjectNullOrUndefinedErr = (prop, value) => {
      const errMessagePath = isCmdIts ? 'its' : 'invoke'

      return $errUtils.cypressErrByPath(`${errMessagePath}.subject_null_or_undefined`, {
        args: {
          prop,
          cmd: name,
          value,
        },
      })
    }

    const propertyNotOnPreviousNullOrUndefinedValueErr = (prop, value, previousProp) => {
      return $errUtils.cypressErrByPath('invoke_its.previous_prop_null_or_undefined', {
        args: {
          prop,
          value,
          previousProp,
          cmd: name,
        },
      })
    }

    const traverseObjectAtPath = (acc, pathsArray, index = 0) => {
      // traverse at this depth
      const prop = pathsArray[index]
      const previousProp = pathsArray[index - 1]
      const valIsNullOrUndefined = _.isNil(acc)

      // if we're attempting to tunnel into
      // a null or undefined object...
      if (isProp(prop) && valIsNullOrUndefined) {
        if (index === 0) {
          // give an error stating the current subject is nil
          traversalErr = subjectNullOrUndefinedErr(prop, acc)
        } else {
          // else refer to the previous property so users know which prop
          // caused us to hit this dead end
          traversalErr = propertyNotOnPreviousNullOrUndefinedValueErr(prop, acc, previousProp)
        }

        return acc
      }

      // if we have no more properties to traverse
      if (!isProp(prop)) {
        if (valIsNullOrUndefined) {
          // set traversal error that the final value is null or undefined
          traversalErr = propertyValueNullOrUndefinedErr(previousProp, acc)
        }

        // finally return the reduced traversed accumulator here
        return acc
      }

      // attempt to lookup this property on the acc
      // if our property does not exist then allow
      // undefined to pass through but set the traversalErr
      // since if we don't have any assertions we want to
      // provide a very specific error message and not the
      // generic existence one
      if (!(prop in primitiveToObject(acc))) {
        traversalErr = propertyNotOnSubjectErr(prop)

        return undefined
      }

      // if we succeeded then continue to traverse
      return traverseObjectAtPath(acc[prop], pathsArray, index + 1)
    }

    const getSettledValue = (value, subject, propAtLastPath) => {
      if (isCmdIts) {
        return value
      }

      if (_.isFunction(value)) {
        return value.apply(subject, args)
      }

      // TODO: this logic should likely be part of
      // traverseObjectAtPath(...) rather be further
      // away from the handling of traversals. this
      // causes us to need to separately handle
      // the 'propAtLastPath' argument since we're
      // outside of the reduced accumulator.

      // if we're not a function and we have a traversal
      // error then throw it now - since that provide a
      // more specific error regarding non-existant
      // properties or null or undefined values
      if (traversalErr) {
        throw traversalErr
      }

      // else throw that prop isn't a function
      $errUtils.throwErrByPath('invoke.prop_not_a_function', {
        onFail: options._log,
        args: {
          prop: propAtLastPath,
          type: $utils.stringifyFriendlyTypeof(value),
        },
      })
    }

    const getValue = () => {
      // reset this on each go around so previous errors
      // don't leak into new failures or upcoming assertion errors
      traversalErr = null

      const remoteSubject = cy.getRemotejQueryInstance(subject)

      const actualSubject = remoteSubject || subject

      let paths = _.isString(str) ? str.split('.') : [str]

      const prop = traverseObjectAtPath(actualSubject, paths)

      const value = getSettledValue(prop, actualSubject, _.last(paths))

      if (options._log) {
        options._log.set({
          consoleProps () {
            const obj = {}

            if (isCmdInvoke) {
              obj['Function'] = message
              if (args.length) {
                obj['With Arguments'] = args
              }
            } else {
              obj['Property'] = message
            }

            _.extend(obj, {
              Subject: getFormattedElement(actualSubject),
              Yielded: getFormattedElement(value),
            })

            return obj
          },
        })
      }

      return value
    }

    // by default we want to only add the default assertion
    // of ensuring existence for cy.its() not cy.invoke() because
    // invoking a function can legitimately return null or undefined
    const ensureExistenceFor = isCmdIts ? 'subject' : false

    // wrap retrying into its own
    // separate function
    const retryValue = () => {
      return Promise
      .try(getValue)
      .catch((err) => {
        options.error = err

        return cy.retry(retryValue, options)
      })
    }

    const resolveValue = () => {
      return Promise
      .try(retryValue)
      .then((value) => {
        return cy.verifyUpcomingAssertions(value, options, {
          ensureExistenceFor,
          onRetry: resolveValue,
          onFail () {
            // if we failed our upcoming assertions and also
            // exited early out of getting the value of our
            // subject then reset the error to this one
            if (traversalErr) {
              options.error = traversalErr
            }
          },
        })
      })
    }

    return resolveValue()
  }

  Commands.addAll({ prevSubject: true }, {
    spread (subject, options, fn) {
      // if this isnt an array-like blow up right here
      if (!_.isArrayLike(subject)) {
        $errUtils.throwErrByPath('spread.invalid_type')
      }

      subject._spreadArray = true

      return thenFn(subject, options, fn)
    },

    each (subject, options, fn) {
      let userOptions = options
      const ctx = this

      if (_.isUndefined(fn)) {
        fn = userOptions
        userOptions = {}
      }

      if (!_.isFunction(fn)) {
        $errUtils.throwErrByPath('each.invalid_argument')
      }

      const nonArray = () => {
        return $errUtils.throwErrByPath('each.non_array', {
          args: { subject: $utils.stringify(subject) },
        })
      }

      try {
        if (!('length' in subject)) {
          nonArray()
        }
      } catch (e) {
        nonArray()
      }

      if (subject.length === 0) {
        return subject
      }

      // if we have a next command then we need to
      // slice in this existing subject as its subject
      // due to the way we queue promises
      const next = state('current').get('next')

      if (next) {
        const checkSubject = (newSubject, args, firstCall) => {
          if (state('current') !== next) {
            return
          }

          // https://github.com/cypress-io/cypress/issues/4921
          // When dual commands like contains() is used as the firstCall (cy.contains() style),
          // we should not prepend subject.
          if (!firstCall) {
            // find the new subject and splice it out
            // with our existing subject
            const index = _.indexOf(args, newSubject)

            if (index > -1) {
              args.splice(index, 1, subject)
            }
          }

          return cy.removeListener('next:subject:prepared', checkSubject)
        }

        cy.on('next:subject:prepared', checkSubject)
      }

      let endEarly = false

      const yieldItem = (el, index) => {
        if (endEarly) {
          return
        }

        if ($dom.isElement(el)) {
          el = $dom.wrap(el)
        }

        const callback = () => {
          const ret = fn.call(ctx, el, index, subject)

          // if the return value is false then return early
          if (ret === false) {
            endEarly = true
          }

          return ret
        }

        return thenFn(el, userOptions, callback, state)
      }

      // generate a real array since bluebird is finicky and
      // doesnt want an 'array-like' structure like jquery instances
      // need to take into account regular arrays here by first checking
      // if its an array instance
      return Promise
      .each(_.toArray(subject), yieldItem)
      .return(subject)
    },
  })

  // temporarily keeping this as a dual command
  // but it will move to a child command once
  // cy.resolve + cy.wrap are upgraded to handle
  // promises
  Commands.addAll({ prevSubject: 'optional' }, {
    then () {
      // eslint-disable-next-line prefer-rest-params
      return thenFn.apply(this, arguments)
    },
  })

  Commands.addAll({ prevSubject: true }, {
    // making this a dual command due to child commands
    // automatically returning their subject when their
    // return values are undefined.  prob should rethink
    // this and investigate why that is the default behavior
    // of child commands
    invoke (subject, optionsOrStr, ...args) {
      return invokeFn.apply(this, [subject, optionsOrStr, ...args])
    },

    its (subject, str, options, ...args) {
      return invokeItsFn.apply(this, [subject, str, options, ...args])
    },
  })
}
