/**
 * Infinispan module.
 * @module
 */

'use strict';

(function() {

  var _ = require('underscore');
  var util = require('util');

  var f = require('./functional');
  var codec = require('./codec');
  var u = require('./utils');
  var protocols = require('./protocols');
  var io = require('./io');
  var listeners = require('./listeners');

  var Client = function(addrs, clientOpts) {
    var logger = u.logger('client');

    function protocolResolver(version) {
      logger.debugf('Using protocol version: %s', version);

      switch (version) {
        case '2.9': return protocols.version29(clientOpts);
        case '2.5': return protocols.version25(clientOpts);
        case '2.2': return protocols.version22(clientOpts);
        default : throw new Error('Unknown protocol version: ' + version);
      }
    }

    var p = protocolResolver(clientOpts['version']);
    var listen = listeners(p);

    var TINY = 16, SMALL = 32, MEDIUM = 64, BIG = 128;

    var transport = io(addrs, p, clientOpts, listen);

    function future(ctx, op, body, decoder, opts) {
      f.actions(p.stepsHeaderBody(ctx, op, body, opts), codec.bytesEncoded)(ctx);
      return transport.writeCommand(ctx, decoder);
    }

    function futureDecodeOnly(ctx, op, decoder, opts) {
      f.actions(p.stepsHeader(ctx, op, opts), codec.bytesEncoded)(ctx);
      return transport.writeCommand(ctx, decoder);
    }

    function futureEmpty(ctx, op) {
      f.actions(p.stepsHeader(ctx, op), codec.bytesEncoded)(ctx);
      return transport.writeCommand(ctx);
    }

    function futureKey(ctx, op, key, body, decoder, opts) {
      f.actions(p.stepsHeaderBody(ctx, op, body, opts), codec.bytesEncoded)(ctx);
      return transport.writeKeyCommand(ctx, key, decoder);
    }

    function futurePinned(ctx, op, body, decoder, conn) {
      f.actions(p.stepsHeaderBody(ctx, op, body), codec.bytesEncoded)(ctx);
      return transport.writeCommandPinned(ctx, decoder, conn);
    }

    function futureExec(ctx, op, body, decoder, opts) {
      f.actions(p.stepsHeaderBody(ctx, op, body, opts), codec.bytesEncoded)(ctx);
      var resultPromise = transport.writeCommand(ctx, decoder);
      return resultPromise.then(function(result) {
        var skipNull = result.replace(/null/g, '\"\"');
        logger.debugf(
          'Replace, if needed, null values for empty strings: original=%s, replaced=%s'
          , result, skipNull
        );
        return skipNull;
      });
    }

    /**
     * Iterator instance returned by completed promise from Client.iterator() calls.
     *
     * @constructs Iterator
     * @since 0.3
     */
    function iterator(iterId, conn) {
      var nextElems = [];
      var done = false;

      function nextPromise() {
        var kv = nextElems.pop();
        return new Promise(function (fulfill, reject) {
          fulfill(f.merge({done: done}, kv));
        });
      }

      function donePromise() {
        return new Promise(function (fulfill, reject) {
          fulfill({done: done});
        });
      }

      return {
        /**
         * Iterator next object returned from completed Iterator.next() calls.
         *
         * @typedef {Object} IteratorNext
         * @property {?(String|Object)} key -
         * If iteration not done, entry's key, otherwise undefined.
         * @property {?(String|Object)} value -
         * If iteration not done, entry's value, otherwise undefined.
         * @property {Boolean} done -
         * Indicates whether iteration has been completed.
         * When true, key and value will be undefined.
         * When false, key and value will be non-null.
         * @since 0.3
         */
        /**
         * Returns the next entry being iterated over.
         *
         * @returns {module:promise.Promise.<IteratorNext>}
         * It returns a Promise which will be completed with an instance that
         * provides the next element.
         * @memberof Iterator#
         * @since 0.3
         */
        next: function() {
          if (done) {
            logger.tracef('Iterator(iteratorId=%s) already exhausted', iterId);
            return donePromise();
          } else if (_.isEmpty(nextElems)) {
            var ctx = transport.context(SMALL);
            logger.tracef(
                'Invoke iterator.next(msgId=%d,iteratorId=%s) on %s'
                , ctx.id, iterId, conn.toString()
            );
            var remote = futurePinned(ctx, 0x33, p.encodeIterId(iterId), p.decodeNextEntries(), conn);
            return remote.then(function(entries) {
              if (_.isEmpty(entries)) {
                done = true;
                return donePromise();
              } else {
                _.each(entries, function(entry) {
                  nextElems.push(entry);
                });
                return nextPromise();
              }
            });
          }
          logger.tracef('Return next from locally cached entries for iterator(iteratorId=%s)', iterId);
          return nextPromise();
        },
        /**
         * Close the iteration.
         *
         * @returns {module:promise.Promise}
         * A Promise which will be completed once the iteration has been closed.
         * @memberof Iterator#
         * @since 0.3
         */
        close: function() {
          var ctx = transport.context(SMALL);
          logger.debugf('Invoke iterator.close(msgId=%d,iteratorId=%s) on %s', ctx.id, iterId, conn.toString());
          return futurePinned(
              ctx, 0x35, p.encodeIterId(iterId), p.complete(p.hasSuccess), conn);
        }
      }
    }

    return {
      connect: function() {
        // TODO: Avoid user calling connect by checking if connected
        var client = this;
        return transport.connect()
            .then(function() { return client.ping(); }) // ping on startup
            .then(function() {
              logger.debugf('Started Infinispan %s', client);
              return client; // return client
            }).catch(function(error){
              transport.disconnect();
              throw error;
            });
      },
      /**
       * Disconnect client from backend server(s).
       *
       * @returns {module:promise.Promise}
       * A promise that will be completed once client has
       * completed disconnection from server(s).
       * @memberof Client#
       * @since 0.3
       */
      disconnect: function() {
        return transport.disconnect();
      },
      /**
       * Get the value associated with the given key parameter.
       *
       * @param k {(String|Object)} Key to retrieve.
       * @returns {module:promise.Promise.<?String>}
       * A promise that will be completed with the value associated with
       * the key, or undefined if the value is not present.
       * @memberof Client#
       * @since 0.3
       */
      get: function(k) {
        var ctx = transport.context(SMALL);
        logger.debugf('Invoke get(msgId=%d,key=%s)', ctx.id, u.str(k));
        var decoder = p.decodeValue();
        return futureKey(ctx, 0x03, k, p.encodeKey(k), decoder);
      },
      /**
       * Check whether the given key is present.
       *
       * @param k {(String|Object)} Key to check for presence.
       * @returns {module:promise.Promise.<boolean>}
       * A promise that will be completed with true if there is a value
       * associated with the key, or false otherwise.
       * @memberof Client#
       * @since 0.3
       */
      containsKey: function(k) {
        var ctx = transport.context(SMALL);
        logger.debugf('Invoke containsKey(msgId=%d,key=%s)', ctx.id, u.str(k));
        return futureKey(ctx, 0x0F, k, p.encodeKey(k), p.complete(p.hasSuccess));
      },
      /**
       * Metadata value.
       *
       * @typedef {Object} MetadataValue
       * @property {(String|Object)} value - Value associated with the key
       * @property {Buffer} version - Version of the value as a byte buffer.
       * @property {Number} lifespan - Lifespan of entry, defined in seconds.
       * If the entry is immortal, it would be -1.
       * @property {Number} maxIdle - Max idle time of entry, defined in seconds.
       * If the entry is no transient, it would be -1.
       * @since 0.3
       */
      /**
       * Get the value and metadata associated with the given key parameter.
       *
       * @param k {(String|Object)} Key to retrieve.
       * @returns {module:promise.Promise.<?MetadataValue>}
       * A promise that will be completed with the value and metadata
       * associated with the key, or undefined if the value is not present.
       * @memberof Client#
       * @since 0.3
       */
      getWithMetadata: function(k) {
        var ctx = transport.context(SMALL);
        logger.debugf('Invoke getWithMetadata(msgId=%d,key=%s)', ctx.id, u.str(k));
        var decoder = p.decodeWithMeta();
        return futureKey(ctx, 0x1B, k, p.encodeKey(k), decoder);
      },
      /**
       * A String formatted to specify duration unit information.
       * Duration unit is formed of two elements, the first is the number of
       * units, and the second is the unit itself: 's' for seconds, 'ms' for
       * milliseconds, 'ns' for nanoseconds, 'μs' for microseconds, 'm' for
       * minutes, 'h' for hours and 'd' for days.
       * So, for example: '1s' would be one second, '5h' five hours...etc.
       *
       * @typedef {String} DurationUnit
       * @since 0.3
       */
      /**
       * Store options defines a set of optional parameters that can be
       * passed when storing data.
       *
       * @typedef {Object} StoreOptions
       * @property {Boolean} previous - Indicates whether previous value
       * should be returned. If no previous value exists, it would return
       * undefined.
       * @property {DurationUnit} lifespan -
       * Lifespan for the stored entry.
       * @property {DurationUnit} maxIdle -
       * Max idle time for the stored entry.
       * @since 0.3
       */
      /**
       * Associates the specified value with the given key.
       *
       * @param k {(String|Object)} Key with which the specified value is to be associated.
       * @param v {(String|Object)} Value to be associated with the specified key.
       * @param opts {?StoreOptions} Optional store options.
       * @returns {module:promise.Promise.<?(String|Object)>}
       * A promise that will be completed with undefined unless 'previous'
       * option has been enabled and a previous value exists, in which case it
       * would return the previous value.
       * @memberof Client#
       * @since 0.3
       */
      put: function(k, v, opts) {
        var ctx = transport.context(MEDIUM);
        logger.debugl(function() { return ['Invoke put(msgId=%d,key=%s,value=%s,opts=%s)',
                                           ctx.id, u.str(k), u.str(v), u.str(opts)]; });
        var decoder = p.decodePrevOrElse(opts, p.hasSuccess, p.complete(_.constant(undefined)));
        return futureKey(ctx, 0x01, k, p.encodeKeyValue(k, v), decoder, opts);
      },
      /**
       * Remove options defines a set of optional parameters that can be
       * passed when removing data.
       *
       * @typedef {Object} RemoveOptions
       * @property {Boolean} previous - Indicates whether previous value
       * should be returned. If no previous value exists, it would return
       * undefined.
       * @since 0.3
       */
      /**
       * Removes the mapping for a key if it is present.
       *
       * @param k {(String|Object)} Key whose mapping is to be removed.
       * @param opts {?RemoveOptions} Optional remove options.
       * @returns {module:promise.Promise.<(Boolean|String|Object)>}
       * A promise that will be completed with true if the mapping was removed,
       * or false if the key did not exist.
       * If the 'previous' option is enabled, it returns the value
       * before removal or undefined if the key did not exist.
       * @memberof Client#
       * @since 0.3
       */
      remove: function(k, opts) {
        var ctx = transport.context(SMALL);
        logger.debugl(function() {return ['Invoke remove(msgId=%d,key=%s,opts=%s)',
                                          ctx.id, u.str(k), JSON.stringify(opts)]; });
        var decoder = p.decodePrevOrElse(opts, p.hasSuccess, p.complete(p.hasSuccess));
        return futureKey(ctx, 0x0B, k, p.encodeKey(k), decoder, opts);
      },
      /**
       * Conditional store operation that associates the key with the given
       * value if the specified key is not already associated with a value.
       *
       * @param k {(String|Object)} Key with which the specified value is to be associated.
       * @param v {(String|Object)} Value to be associated with the specified key.
       * @param opts {?StoreOptions} Optional store options.
       * @returns {module:promise.Promise.<(Boolean|String|Object)>}
       * A promise that will be completed with true if the mapping was stored,
       * or false if the key is already present.
       * If the 'previous' option is enabled, it returns the existing value
       * or undefined if the key does not exist.
       * @memberof Client#
       * @since 0.3
       */
      putIfAbsent: function(k, v, opts) {
        var ctx = transport.context(MEDIUM);
        logger.debugl(function() {return ['Invoke putIfAbsent(msgId=%d,key=%s,value=%s,opts=%s)',
                                          ctx.id, u.str(k), u.str(v), JSON.stringify(opts)]; });
        var decoder = p.decodePrevOrElse(opts, p.hasNotExecuted, p.complete(p.hasSuccess));
        return futureKey(ctx, 0x05, k, p.encodeKeyValue(k, v), decoder, opts);
      },
      /**
       * Conditional store operation that replaces the entry for a key only
       * if currently mapped to a given value.
       *
       * @param k {(String|Object)} Key with which the specified value is associated.
       * @param v {(String|Object)} Value expected to be associated with the specified key.
       * @param opts {?StoreOptions} Optional store options.
       * @returns {module:promise.Promise.<(Boolean|String|Object)>}
       * A promise that will be completed with true if the mapping was replaced,
       * or false if the key does not exist.
       * If the 'previous' option is enabled, it returns the value that was
       * replaced or undefined if the key did not exist.
       * @memberof Client#
       * @since 0.3
       */
      replace: function(k, v, opts) {
        var ctx = transport.context(MEDIUM);
        logger.debugl(function() { return ['Invoke replace(msgId=%d,key=%s,value=%s,opts=%s)',
                                           ctx.id, u.str(k), u.str(v), JSON.stringify(opts)]; });
        var decoder = p.decodePrevOrElse(opts, p.hasPrevious, p.complete(p.hasSuccess));
        return futureKey(ctx, 0x07, k, p.encodeKeyValue(k, v), decoder, opts);
      },
      /**
       * Replaces the given value only if its version matches the supplied
       * version.
       *
       * @param k {(String|Object)} Key with which the specified value is associated.
       * @param v {(String|Object)} Value expected to be associated with the specified key.
       * @param version {Buffer} binary buffer version that should match the
       * one in the server for the operation to succeed. Version information
       * can be retrieved with getWithMetadata method.
       * @param opts {?StoreOptions} Optional store options.
       * @returns {module:promise.Promise.<(Boolean|String|Object)>}
       * A promise that will be completed with true if the version matches
       * and the mapping was replaced, otherwise it returns false if not
       * replaced because key does not exist or version sent does not match
       * server-side version.
       * If the 'previous' option is enabled, it returns the value that was
       * replaced if the version matches. If the version does not match, the
       * current value is returned. Fianlly if the key did not exist it
       * returns undefined.
       * @memberof Client#
       * @since 0.3
       */
      replaceWithVersion: function(k, v, version, opts) {
        var ctx = transport.context(MEDIUM);
        logger.debugl(function() { return ['Invoke replaceWithVersion(msgId=%d,key=%s,value=%s,version=0x%s,opts=%s)',
                                           ctx.id, u.str(k), u.str(v), version.toString('hex'), JSON.stringify(opts)]; });
        var decoder = p.decodePrevOrElse(opts, p.hasPrevious, p.complete(p.hasSuccess));
        return futureKey(ctx, 0x09, k, p.encodeKeyValueVersion(k, v, version), decoder, opts);
      },
      /**
       * Removes the given entry only if its version matches the
       * supplied version.
       *
       * @param k {(String|Object)} Key whose mapping is to be removed.
       * @param version {Buffer} binary buffer version that should match the
       * one in the server for the operation to succeed. Version information
       * can be retrieved with getWithMetadata method.
       * @param opts {?RemoveOptions} Optional remove options.
       * @returns {module:promise.Promise.<(Boolean|String|Object)>}
       * A promise that will be completed with true if the version matches
       * and the mapping was removed, otherwise it returns false if not
       * removed because key does not exist or version sent does not match
       * server-side version.
       * If the 'previous' option is enabled, it returns the value that was
       * removed if the version matches. If the version does not match, the
       * current value is returned. Fianlly if the key did not exist it
       * returns undefined.
       * @memberof Client#
       * @since 0.3
       */
      removeWithVersion: function(k, version, opts) {
        var ctx = transport.context(SMALL);
        logger.debugl(function() { return ['Invoke removeWithVersion(msgId=%d,key=%s,version=0x%s,opts=%s)',
                                           ctx.id, u.str(k), version.toString('hex'), JSON.stringify(opts)]; });
        var decoder = p.decodePrevOrElse(opts, p.hasPrevious, p.complete(p.hasSuccess));
        return futureKey(ctx, 0x0D, k, p.encodeKeyVersion(k, version), decoder, opts);
      },
      /**
       * Key/value entry.
       *
       * @typedef {Object} Entry
       * @property {(String|Object)} key - Entry's key.
       * @property {(String|Object)} value - Entry's value.
       * @since 0.3
       */
      /**
       * Retrieves all of the entries for the provided keys.
       *
       * @param keys {(String[]|Object[])} Keys to find values for.
       * @returns {module:promise.Promise.<Entry[]>}
       * A promise that will be completed with an array of entries for all
       * keys found. If a key does not exist, there won't be an entry for that
       * key in the returned array.
       * @memberof Client#
       * @since 0.3
       */
      getAll: function(keys) {
        var ctx = transport.context(MEDIUM);
        logger.debugf('Invoke getAll(msgId=%d,keys=%s)', ctx.id, u.str(keys));
        // TODO: Validate empty keys
        return future(ctx, 0x2F, p.encodeMultiKey(keys), p.decodeCountValues());
      },
      /**
       * Multi store options defines a set of optional parameters that can be
       * passed when storing multiple entries.
       *
       * @typedef {Object} MultiStoreOptions
       * @property {DurationUnit} lifespan -
       * Lifespan for the stored entry.
       * @property {DurationUnit} maxIdle -
       * Max idle time for the stored entry.
       * @since 0.3
       */
      /**
       * Stores all of the mappings from the specified entry array.
       *
       * @param pairs {Entry[]} key/value pair mappings to be stored
       * @param opts {MultiStoreOptions}
       * Optional storage options to apply to all entries.
       * @returns {module:promise.Promise}
       * A promise that will be completed when all entries have been stored.
       * @memberof Client#
       * @since 0.3
       */
      putAll: function(pairs, opts) {
        var ctx = transport.context(BIG);
        logger.debugl(function() { return ['Invoke putAll(msgId=%d,pairs=%s,opts=%s)',
                                           ctx.id, JSON.stringify(pairs), JSON.stringify(opts)]; });
        return future(ctx, 0x2D, p.encodeMultiKeyValue(pairs), p.complete(_.constant(undefined)), opts);
      },
      /**
       * Iterator options defines a set of optional parameters that
       * control how iteration occurs and the data that's iterated over.
       *
       * @typedef {Object} IteratorOptions
       * @property {Boolean} metadata - Indicates whether entries iterated
       * over also expose metadata information. This option is false by
       * default which means no metadata information is exposed on iteration.
       * @since 0.3
       */
      /**
       * Iterate over the entries stored in server(s).
       *
       * @param batchSize {Number}
       * The number of entries transferred from the server at a time.
       * @param opts {?IteratorOptions} Optional iteration settings.
       * @return {module:promise.Promise.<Iterator>}
       * A promise that will be completed with an iterator that can be used
       * to retrieve stored elements.
       * @memberof Client#
       * @since 0.3
       */
      iterator: function(batchSize, opts) {
        var ctx = transport.context(SMALL);
        logger.debugf('Invoke iterator(msgId=%d,batchSize=%d,opts=%s)', ctx.id, batchSize, u.str(opts));
        var remote = future(ctx, 0x31, p.encodeIterStart(batchSize, opts), p.decodeIterId);
        return remote.then(function(result) {
          return iterator(result.iterId, result.conn);
        });
      },
      /**
       * Count of entries in the server(s).
       *
       * @returns {module:promise.Promise.<Number>}
       * A promise that will be completed with the number of entries stored.
       * @memberof Client#
       * @since 0.3
       */
      size: function() {
        var ctx = transport.context(TINY);
        logger.debugf('Invoke size(msgId=%d)', ctx.id);
        return futureDecodeOnly(ctx, 0x29, p.decodeVInt);
      },
      /**
       * Clear all entries stored in server(s).
       *
       * @returns {module:promise.Promise}
       * A promise that will be completed when the clear has been completed.
       * @memberof Client#
       * @since 0.3
       */
      clear: function() {
        var ctx = transport.context(TINY);
        logger.debugf('Invoke clear(msgId=%d)', ctx.id);
        return futureEmpty(ctx, 0x13);
      },
      /**
       * Pings the server(s).
       *
       * @returns {module:promise.Promise}
       * A promise that will be completed when ping response was received.
       * @memberof Client#
       * @since 0.3
       */
      ping: function() {
        var ctx = transport.context(TINY);
        logger.debugf('Invoke ping(msgId=%d)', ctx.id);
        return futureDecodeOnly(ctx, 0x17, p.decodeServerMediaTypes);
      },
      /**
       * Statistic item.
       *
       * @typedef {Object} StatsItem
       * @property {String} STAT_NAME -
       * Name of the statistic.
       * @property {String} STAT_VALUE -
       * Value of the statistic.
       * @since 0.3
       */
      /**
       * Retrieve various statistics from server(s).
       *
       * @returns {module:promise.Promise<StatsItem[]>}
       * A promise that will be completed with an array of statistics, where
       * each element will have a single property. This single property will
       * have the statistic name as property name and statistic value as
       * property value.
       * @memberof Client#
       * @since 0.3
       */
      stats: function() {
        var ctx = transport.context(TINY);
        logger.debugf('Invoke stats(msgId=%d)', ctx.id);
        return futureDecodeOnly(ctx, 0x15, p.decodeStringPairs);
      },
      /**
       * Listener options.
       *
       * @typedef {Object} ListenOptions
       * @property {String} listenerId - Listener identifier can be passed
       * in as parameter to register multiple event callback functions for
       * the same listener.
       * @since 0.3
       */
      /**
       * Add an event listener.
       *
       * @param {String} event
       * Event to add listener to. Possible values are:
       * 'create', 'modify', 'remove' and 'expiry'.
       * @param {Function} listener
       * Function to invoke when the listener event is received.
       * 'create' and 'modify' events callback the function with key,
       * entry version and listener id.
       * 'remove' and 'expiry' events callback the function with key
       * and listener id.
       * @param opts {?ListenOptions} Options for adding listener.
       * @returns {module:promise.Promise<String>}
       * A promise that will be completed with the identifier of the listener.
       * This identifier can be used to register multiple callbacks with the
       * same listener, or to remove the listener.
       * @memberof Client#
       * @since 0.3
       */
      addListener: function(event, listener, opts) {
        var ctx = transport.context(SMALL);
        return _.has(opts, 'listenerId')
            ? listen.addLocalListener(ctx, event, listener, opts)
            : listen.addRemoteListener(transport, ctx, event, listener, opts);
      },
      /**
       * Remove an event listener.
       *
       * @param {String} listenerId
       * Listener identifier to identify listener to remove.
       * @return {module:promise.Promise}
       * A promise that will be completed when the listener has been removed.
       * @memberof Client#
       * @since 0.3
       */
      removeListener: function(listenerId) {
        var ctx = transport.context(SMALL);
        logger.debugf('Invoke removeListener(msgId=%d,listenerId=%s) remotely', ctx.id, listenerId);
        var conn = listen.findConnectionListener(listenerId);
        if (!f.existy(conn))
          return Promise.reject(
            new Error('No server connection for listener (listenerId=' + listenerId + ')'));

        var remote = futurePinned(ctx, 0x27, p.encodeListenerId(listenerId), p.complete(p.hasSuccess), conn);
        return remote.then(function (success) {
          if (success) {
            listen.removeListeners(listenerId);
            return true;
          }
          return false;
        })
      },
      /**
       * Add script to server(s).
       *
       * @param {String} scriptName Name of the script to store.
       * @param {String} script Script to store in server.
       * @return {module:promise.Promise}
       * A promise that will be completed when the script has been stored.
       * @memberof Client#
       * @since 0.3
       */
      addScript: function(scriptName, script) {
        var scriptClientOpts = f.merge(clientOpts, {cacheName: '___script_cache'});
        var scriptClient = new Client(addrs, scriptClientOpts);
        logger.debugf('Invoke addScript(scriptName=%s)', scriptName);
        return scriptClient.connect().then(function(c) {
          return c.put(scriptName, script)
              .finally(function() { return c.disconnect(); })
        });
      },
      /**
       * Script execution parameters.
       *
       * @typedef {Object} ExecParams
       * @property {String} PARAM_NAME -
       * Name of the parameter.
       * @property {String} PARAM_VALUE -
       * Value of the parameter.
       * @since 0.3
       */
      /**
       * Execute the named script passing in optional parameters.
       *
       * @param {String} scriptName Name of the script to execute.
       * @param {?ExecParams[]} params
       * Optional array of named parameters to pass to script in server.
       * @returns {module:promise.Promise<String|String[]>}
       * A promise that will be completed with either the value returned by the
       * script after execution for local scripts, or an array of values
       * returned by the script when executed in multiple servers for
       * distributed scripts.
       * @memberof Client#
       * @since 0.3
       */
      execute: function(scriptName, params) {
        var ctx = transport.context(SMALL);
        logger.debugf('Invoke execute(msgId=%d,scriptName=%s,params=%s)', ctx.id, scriptName, u.str(params));
        // TODO update jsdoc, value does not need to be String, can be JSON too
        return futureExec(ctx, 0x2B, p.encodeNameParams(scriptName, params), p.decodeValue());
      },
      /**
       * Get server topology related information.
       *
       * @returns {TopologyInfo}
       * An object instance that can be used to query diverse information
       * related to the server topology information.
       * @memberof Client#
       * @since 0.3
       */
      getTopologyInfo: function() {
        return new TopologyInfo(transport);
      },
      /**
       * Get client information represented as a string.
       * @memberof Client#
       * @since 0.4
       */
      toString: function() {
        return util.format('Client(%s)', transport);
      }
    }
  };

  /**
   * Server topology information.
   *
   * @constructs Topology
   * @since 0.3
   */
  var TopologyInfo = function(transport) {
    return {
      /**
       * Get the server topology identifier.
       *
       * @returns {Number} Topology identifier.
       * @memberof Topology#
       * @since 0.3
       */
      getTopologyId: function() {
        return transport.getTopologyId();
      },
      /**
       * Get the list of servers that the client is currently connected to.
       *
       * @return {ServerAddress[]} An array of server addresses.
       * @memberof Topology#
       * @since 0.3
       */
      getMembers: function() {
        return transport.getMembers();
      },
      /**
       * Find the list of server addresses that are owners for a given key.
       *
       * @param {(String|Object)} k Key to find owners for.
       * @return {ServerAddress[]}
       * An array of server addresses that are owners for the given key.
       * @memberof Topology#
       * @since 0.3
       */
      findOwners: function(k) {
        return transport.findOwners(k);
      },
      /**
       * Switch remote cache manager to a different cluster,
       * previously declared via configuration.
       *
       * @param clusterName name of the cluster to which to switch to
       * @return {module:promise.Promise<Boolean>}
       * A promise encapsulating a Boolean that indicates {@code true} if the
       * switch happened, or {@code false} otherwise.
       * @memberof Topology#
       * @since 0.4
       */
      switchToCluster: function(clusterName) {
        return transport.switchToCluster(clusterName);
      },
      /**
       * Switch remote cache manager to the default cluster,
       * previously declared via configuration.
       *
       * @return {module:promise.Promise<Boolean>}
       * A promise encapsulating a Boolean that indicates {@code true} if the
       * switch happened, or {@code false} otherwise.
       * @memberof Topology#
       * @since 0.4
       */
      switchToDefaultCluster: function() {
        return transport.switchToDefaultCluster();
      }
    }
  };

  /**
   * Server address.
   *
   * @typedef {Object} ServerAddress
   * @property {String} host - Server host name.
   * @property {Number} port - Server port.
   * @since 0.3
   */
  /**
   * Infinispan client constructor taking an optional initial address,
   * or multiple addresses, to which the client will try to connect to,
   * as well as optional configuration settings.
   *
   * @example
   * client({port: 11222, host: 'localhost'})
   *
   * @example
   * client([{port: 11322, host: 'node1'}, {port: 11422, host: 'node2'}])
   *
   * @example
   * client({port: 11522, host: 'myhost'}, {version: '2.2'})
   *
   * @example
   * client([{port: 11522, host: 'myhost'}, {port: 11622, host: 'myhost'}],
   *        {version: '2.2', cacheName: 'myCache'})
   *
   * @param args {(ServerAddress|ServerAddress[])}
   * Optional single or multiple addresses to which to connect. If none
   * provided, the client will connect to localhost:11222 address by default.
   * @param options {module:infinispan.ClientOptions}
   * Optional configuration settings.
   * @returns A promise that will be completed once the connection
   * has been established. The promise will be completed with a client
   * instance on which operations can invoked.
   * @constructs Client
   * @since 0.3
   */
  exports.client = function client(args, options) {
    var merged = f.merge(Client.config, options);
    var c = new Client(u.normalizeAddresses(args), merged);
    return c.connect();
  };

  /**
   * Cluster information.
   *
   * @typedef {Object} Cluster
   * @property {String} name - Cluster name.
   * @property {ServerAddress[]} servers - Cluster servers details.
   * @since 0.3
   */
  /**
   * Client configuration settings. Object instances that override
   * these configuration options can be used on client construction to tweak
   * its behaviour.
   *
   * @static
   * @typedef {Object} ClientOptions
   * @property {?(2.9|2.5|2.2)} [version=2.9] - Version of client/server protocol.
   * @property {?String} cacheName - Optional cache name.
   * @property {?Number} [maxRetries=3] - Optional number of retries for operation.
   * @property {?boolean} [ssl.enabled=false] - Optional flag to enable SSL support.
   * @property {?String} [ssl.secureProtocol=TLSv1_2_method] - Optional field with secure protocol in use.
   * @property {?String[]} ssl.trustCerts - Optional paths of trusted SSL certificates.
   * @property {?String} ssl.clientAuth.key - Optional path to client authentication key.
   * @property {?String} ssl.clientAuth.passphrase - Optional password for client key.
   * @property {?String} ssl.clientAuth.cert - Optional client certificate.
   * @property {?String} ssl.sniHostName - Optional SNI host name.
   * @property {?String} ssl.cryptoStore.path - Optional crypto store path.
   * @property {?String} ssl.cryptoStore.passphrase - Optional password for crypto store.
   * @property {?boolean} [topologyUpdates=true] - Optional flag to controls whether the client deals with topology updates or not.
   * @property {?(text/plain|application/json)} [mediaType=text/plain] - Media type of the cache contents.
   * @property {?Cluster[]} clusters - Optional additional clusters for cross-site failovers.
   * @since 0.3
   */
  Client.config = {
    version: '2.9',         // Hot Rod protocol version
    cacheName: undefined,   // Cache name
    maxRetries: 3,           // Maximum number of retries
    ssl : {
      enabled: false,
      secureProtocol: 'TLSv1_2_method',
      trustCerts: [],
      clientAuth: {
        key: undefined,
        passphrase: undefined,
        cert: undefined
      },
      sniHostName: undefined,
      cryptoStore: {
        path: undefined,
        passphrase: undefined
      }
    },
    dataFormat : {
      keyType: 'text/plain',
      valueType: 'text/plain'
    },
    topologyUpdates: true,
    clusters: []
  };

}.call(this));
