import {
  always,
  assoc,
  cond,
  converge,
  curry,
  either,
  equals,
  filter,
  fromPairs,
  identity,
  is,
  mapObjIndexed,
  merge,
  omit,
  pathEq,
  pipe,
  prop,
  reject,
  toPairs,
  tryCatch,
  T,
  test as regTest,
  unless,
  when
} from 'ramda'

/**
 * This is what you really mean when you're checking to see if something is an Object.
 * Honestly who cares that an Array, Date and RegExp are technically objects too?
 * You were looking for the thing with the curly braces {} right?
 *
 * @func
 * @sig * -> Boolean
 * @param {*} val A value of any type
 * @returns {Boolean} Whether or not the value is an Object
 */
export const isPlainObj = pathEq(['constructor', 'name'], 'Object')

/**
 * Turns any non-Object values into empty objects
 *
 * @func
 * @sig * -> {k: v}
 * @param {*} val A value of any type
 * @returns {Object} If the original value was an Object, it is returned as-is,
 * otherwise an empty object is returned
 */
export const objectify = unless(isPlainObj, always({}))

/**
 * If the provided value is NOT a function, transform it into a function that
 * always returns that value
 *
 * @func
 * @sig * -> (* -> *)
 * @param {*} val A value of any type
 * @returns {Function} If the original value was a function, that function is returned,
 * otherwise a function that always returns the original value is returned
 * function that always returns that value OR
 */
export const alwaysFunction = unless(is(Function), always)

/**
 * If the provided value is neither an Object nor a Function, transform it into
 * a function that always returns that provided value.
 *
 * @func
 * @sig * -> (* -> *)|{k: v}
 * @param {*} val A value of any type
 * @returns {Function|Object} A function that always returns the provided value
 * OR the original value (if it was already a Function or an Object)
 */
export const makeFunctionUnlessObject = unless(either(is(Function), isPlainObj), always)

/**
 * Makes sure a given spec is acceptable for one of the pruning modes (ie, "remove" or "keep").
 * Specs in this mode are treated more strictly, meaning the prop must be given
 * a value of "true" or the key and value can be identical.
 * Functions are acceptable values in the spec of course.
 *
 * @func
 * @sig {k: v} -> {k: v}
 * @param {Object} spec A spec to be coerced into an acceptable pruning spec
 * @returns {Object} A spec that is acceptable to be used in pruning mode.
 */
export const makePruningSpec = pipe(
  objectify,
  toPairs,
  filter(([key, val]) => (key === val || val === true || is(Function, val))),
  fromPairs,
)

/**
 * Filters a spec (object) to only the props that are transform functions. If
 * the input is not an object, this is just an identity function.
 *
 * @func
 * @sig {k: v} -> {k: v}
 * @param {Object} spec An object whose values (may) be transform functions
 * @returns {Object} The input object with only the props that are functions retained
 */
export const onlySpecTransforms = when(
  isPlainObj,
  pipe(
    toPairs,
    filter(([key, val]) => (key === 'shapeyDebug' || is(Function, val))),
    fromPairs
  )
)

/**
 * Removes the reserved "shapey*" prefixed props from a spec.
 *
 * @func
 * @sig {k: v} -> {k: v}
 * @param {Object} spec A shapey spec to be pruned of magic props
 * @returns {Object} A shapey spec cleaned of magic props
 */
export const removeMagicProps = pipe(
  toPairs,
  reject(([key]) => regTest(/^shapey/i, key)),
  fromPairs
)

/**
 * Takes an non-transform props (non-functions) in a given spec and merges them onto the input object.
 *
 * @func
 * @sig {k: v} -> {k: v} -> {k: v}
 * @param {Object} spec An object whose values are either transform functions or pass-through values to be added to the return object
 * @param {Object} input An object which will be passed through the re-shaping transform functions defined by the spec
 * @returns {Object} The input object with only the pass-through props applied to it
 */
export const applyNonTransformProps = curry(
  (spec, value) => (
    [spec, value].every(isPlainObj) ?
      pipe(removeMagicProps, reject(is(Function)), merge(value))(spec) :
      value
  )
)

/**
 * Logs to the console any failed transform functions, along with the field name
 * and the value that was fed into the transform function. Plus, the exception
 * itself is also logged.
 *
 * @func
 * @sig String -> * -> Error -> *
 * @param {String} fieldName The field name for the failed transform function
 * @param {Object} value The value that was fed into the transform function
 * @param {Object} exception The exception thrown by the failed transform
 * @returns {undefined}
 */
const defaultErrorHandler = curry((fieldName, value, exception) =>
  // eslint-disable-next-line no-console
  console.error(
    `\n Transform failed on field: "${fieldName}"\n value:`,
    value,
    '\n',
    exception
  )
)

/**
 * A wrapper around an (optional) error handler that the may be passed in as the
 * "shapeyDebug" value. It is curried, but will feed their handler a more
 * standard signature of: (err, field, value)
 *
 * @func
 * @sig ((Error, String, *) -> *) -> String -> * -> Error -> *
 * @param {Function} errHandler The custom error handler provided by the consumer
 * @param {String} fieldName The field name for the failed transform function
 * @param {Object} value The value that was fed into the transform function
 * @param {Object} exception The exception thrown by the failed transform
 * @returns {*} Could be anything, or nothing (it's up to the consumer)
 */
const wrapperForTheirErrorHandler = curry(
  (errHandler, fieldName, value, exception) => errHandler(exception, fieldName, value)
)

/**
 * Wraps every function on a given spec in a try/catch that catches any exception.
 *
 * What it _does_ with the exception is up to the consumer:
 *   - ignores it (if "shapeyDebug" isn't set)
 *   - logs it along with the corresponding field name,
 *     using `console.error` (if "shapeyDebug" is set to `true`)
 *   - uses a custom handler supplied by the consumer as the value for "shapeyDebug"
 *
 * Note that a custom error handler will be passed the following params (in order):
 *   - The exception
 *   - The field name
 *   - The value that was fed into the transform function
 *
 * @func
 * @sig {k: (a -> b)} -> {k: (a -> b)}
 * @param {Object} spec An object whose values may be transform functions that
 * need to be wrapped in try/catch
 * @returns {Object} The same spec object but whose functions are
 * safely wrapped in in try/catch handlers
 */
export const safeSpecTransforms = pipe(
  objectify,
  converge(assoc('shapeyDebug'), [
    pipe(
      prop('shapeyDebug'),
      cond([
        [equals(true), always(defaultErrorHandler)],
        [is(Function), wrapperForTheirErrorHandler],
        [regTest(/^skip$/i), () => (_, originalValue) => always(originalValue)],
        [T, () => () => always(undefined)]
      ])
    ),
    identity
  ]),
  mapObjIndexed((transform, fieldName, obj) =>
    when(
      is(Function),
      curry((fn, val) => tryCatch(fn, obj.shapeyDebug(fieldName, val))(val))
    )(transform)
  ),
  omit(['shapeyDebug'])
)