import count from 'lodash/size';
import isEmpty from 'lodash/isEmpty';

import {
  isTransducer,
  ITERATOR,
  TRANSDUCER_INIT,
  TRANSDUCER_RESULT,
  TRANSDUCER_STEP
} from '@hora/protocols';

import type, {
  isFunction,
  isIFunction,
  isNil,
  isPlainObject
} from '@hora/type';

export { count, isEmpty, isNil, isPlainObject, type };

/**
 * @param  {Array} array
 * @return {Array}
 */
export function chunk(array) {
  console.assert(
    Array.isArray(array) && array.length % 2 === 0,
    'chunk: first argument must be an array with a pair number of arguments'
  );
  let pairs = [];
  for (var i = 0; i < array.length; i += 2) {
    pairs.push([array[i], array[i + 1]]);
  }
  return pairs;
}

/**
 * @param {Array|Map|Set|Object} coll
 */
export function clone(coll) {
  if (Array.isArray(coll)) {
    return Array.from(coll);
  } else if (coll instanceof Map) {
    return new Map(coll);
  } else if (coll instanceof Set) {
    return new Set(coll);
  } else if (isPlainObject(coll)) {
    return Object.assign(Object.create(null), coll);
  }

  return coll;
}

/**
 * Takes a set of functions and returns a function that is the composition
 * of those functions. The returned function takes a variable number
 * of arguments, applies the rightmost of functions to the arguments, the next
 * function (right-to-left) to the result, etc.
 * 
 * @param  {...Function} fns
 * @return {Function}
 */
export function compose(...fns) {
  console.assert(
    isIFunction(fns[0]),
    'compose: first argument must be a function.'
  );
  fns = fns.map(toFunction).reverse();
  let f = fns.shift();
  return (...args) => fns.reduce((x, f) => f(x), f(...args));
}

/**
 * Takes a set of functions and returns a function that is the juxtaposition
 * of those functions. The returned function takes a variable number of args,
 * and returns an array containing the result of applying each function to the
 * args (left-to-right).
 * 
 * @param  {...Function} fns
 * @return {Function}
 */
export function juxt(...fns) {
  console.assert(
    isIFunction(fns[0]),
    'juxt: first argument must be a function.'
  );
  fns = fns.map(toFunction);
  return (...args) => fns.map(f => f(...args));
}

/**
 * Takes a function `f` and fewer than the normal arguments to `f`, and
 * returns a function that takes a variable number of additional arguments.
 * When called, the returned function calls `f` with original arguments and
 * additional arguments.
 * 
 * @param  {Function} f
 * @return {Function}
 */
export function partial(f, ...args) {
  console.assert(isIFunction(f), 'partial: first argument must be a function.');
  if (!args.length) {
    return f;
  }
  f = toFunction(f);
  return (...moreArgs) => f(...args.concat(moreArgs));
}

/**
 * Returns a function that takes any number of arguments and always returns `x`.
 * 
 * @return {Function}
 */
export function constantly(x) {
  return () => x;
}

/**
 * Returns its argument.
 */
export function identity(x) {
  return x;
}

/**
 * Takes a function `f` and returns a function that takes the same
 * arguments as `f`, has the same effects, if any, and returns the
 * opposite truth value.
 *
 * @param  {Function} f
 * @return {Function}
 */
export function complement(f) {
  console.assert(
    isIFunction(f),
    'complement: first argument must be a function.'
  );
  f = toFunction(f);
  return (...args) => isFalsey(f(...args));
}

/**
 * Applies function `f` to the argument list formed by prepending
 * intervening arguments to arguments array in the last position.
 *
 * @param {Function} f
 */
export function apply(f, ...args) {
  let a = args.pop();
  console.assert(isIFunction(f), 'apply: first argument must be a function.');
  console.assert(
    Array.isArray(a),
    'apply: last argument must be an array of arguments.'
  );
  return f.apply(f, args.concat(a));
}

/**
 * Takes a function `f`, and returns a function that calls `f`, replacing
 * a `nil` first argument to `f` with the supplied value `x`. Higher arity
 * versions can replace arguments in the second and third
 * positions (`y`, `z`). Note that the function `f` can take any number of
 * arguments, not just the one(s) being nil-patched.
 *
 * @param  {Function} f
 * @param  {*} x
 * @param  {*} [y]
 * @param  {*} [z]
 * @return {Function}
 */
export function fnil(f, x, y, z) {
  console.assert(isIFunction(f), 'fnil: first argument must be a function.');
  console.assert(
    arguments.length >= 2,
    `fnil: wrong number of arguments (${arguments.length}).`
  );
  switch (arguments.length) {
    case 2:
      return (a, ...args) => f(nil(a, x), ...args);
    case 3:
      return (a, b, ...args) => f(nil(a, x), nil(b, y), ...args);
    case 4:
      return (a, b, c, ...args) => f(nil(a, x), nil(b, y), nil(c, z), ...args);
  }
}

export function isBlank(maybeBlank) {
  switch (typeof maybeBlank) {
    case 'object':
      return isEmpty(maybeBlank);
    case 'string':
      return maybeBlank.length === 0 || isEmpty(maybeBlank.trim());
    case 'boolean':
      return maybeBlank === false;
    default:
      return isNil(maybeBlank);
  }
}

/**
 * @return {Boolean}
 */
export function isFalsey(maybeFalsey) {
  return isNil(maybeFalsey) || maybeFalsey === false;
}

/**
 * @return {Boolean}
 */
export function isSome(maybeSome) {
  return !isNil(maybeSome);
}

/**
 * @return {Boolean}
 */
export function isTruthy(maybeTruthy) {
  return !isFalsey(maybeTruthy);
}

/**
 * @return {Boolean}
 */
export function isPresent(maybePresent) {
  return !isBlank(maybePresent);
}

/**
 * @param {?*} maybeNil
 * @param {*} [value]
 */
export function nil(maybeNil, value = null) {
  return isNil(maybeNil) ? value : maybeNil;
}

/**
 * Wrap protocols in plain function
 */
export function toFunction(f) {
  if (isFunction(f)) {
    return f;
  }
  if (isTransducer(f)) {
    return function(result, input) {
      switch (arguments.length) {
        case 0:
          return f[TRANSDUCER_INIT]();
        case 1:
          return f[TRANSDUCER_RESULT](result);
        default:
          return f[TRANSDUCER_STEP](result, input);
      }
    };
  }
  console.assert(
    isIFunction(f),
    'toFunction: passed argument do not provide IFunction or Transformer protocol.'
  );
  return (...args) => f.apply(f, args);
}

const hasOwnProperty = Object.prototype.hasOwnProperty;
const EMPTY_ITERATOR = [][ITERATOR]();

/**
 * @param  {Object}
 * @return {Iterator}
 */
export function plainObjectIterator(obj) {
  return (function*() {
    for (let key in obj) {
      if (hasOwnProperty.call(obj, key)) {
        yield [key, obj[key]];
      }
    }
  })();
}

/**
 * @param  {Array|Map|Set|Object|Iterator}
 * @return {Iterator}
 */
export function iterator(coll) {
  if (isPlainObject(coll)) {
    return plainObjectIterator(coll);
  }

  if (coll == null) {
    return EMPTY_ITERATOR;
  }

  let iter = coll && coll[ITERATOR];

  if (iter) {
    return iter.call(coll);
  }
}

/**
 * @return {Array}
 */
export function toArray(coll) {
  return Object.freeze(Array.from(iterator(coll)));
}
