context.js

import * as objects from './ponyfills/objects.js';
import * as arrays from './helpers/arrays.js';
import { emit } from './waitforit.js';

export const CONTEXT_CHANGED = 'context.changed';
export const CONTEXT_INITIALIZED = 'context.initialized';
export const CONTEXT_VALUE_REMOVED = 'context.value.removed';
export const CONTEXT_VALUE_ADDED = 'context.value.added';
export const CONTEXT_VALUE_CHANGED = 'context.value.changed';
export const CONTEXT_DESTROYED = 'context.destroyed';

export const DEFAULT_QUEUE_LIMIT = 50;

/**
 * The EvolvContext provides functionality to manage data relating to the client state, or context in which the
 * variants will be applied.
 *
 * This data is used for determining which variables are active, and for general analytics.
 *
 * @constructor
 */
function EvolvContext(store) {
  let uid;
  let remoteContext;
  let localContext;
  let initialized = false;

  /**
   * A unique identifier for the participant.
   */
  Object.defineProperty(this, 'uid', { get: function() { return uid; } });

  /**
   * The context information for evaluation of predicates and analytics.
   */
  Object.defineProperty(this, 'remoteContext', { get: function() { return objects.deepClone(remoteContext); } });

  /**
   * The context information for evaluation of predicates only, and not used for analytics.
   */
  Object.defineProperty(this, 'localContext', { get: function() { return objects.deepClone(localContext); } });

  function mutableResolve() {
    return objects.deepMerge(localContext, remoteContext);
  }

  function ensureInitialized() {
    if (!initialized) {
      throw new Error('Evolv: The evolv context is not initialized')
    }
  }

  this.initialize = function(_uid, _remoteContext, _localContext) {
    if (initialized) {
      throw new Error('Evolv: The context is already initialized');
    }
    uid = _uid;
    remoteContext = _remoteContext ? objects.deepClone(_remoteContext) : {};
    localContext = _localContext ? objects.deepClone(_localContext) : {};
    initialized = true;
    emit(this, CONTEXT_INITIALIZED, this.resolve());
  };

  this.destroy = function() {
    remoteContext = undefined;
    localContext = undefined;
    emit(this, CONTEXT_DESTROYED, this);
  };

  /**
   * Computes the effective context from the local and remote contexts.
   *
   * @returns {Object} The effective context from the local and remote contexts.
   */
  this.resolve = function() {
    ensureInitialized();
    return objects.deepClone(mutableResolve());
  };

  /**
   * Sets a value in the current context.
   *
   * Note: This will cause the effective genome to be recomputed.
   *
   * @param {String} key The key to associate the value to.
   * @param {*} value  The value to associate with the key.
   * @param {Boolean} [local = false] If true, the value will only be added to the localContext.
   */
  this.set = function(key, value, local) {
    ensureInitialized();
    const context = local ? localContext : remoteContext;
    const before = objects.getValueForKey(key, context);

    if (before === value || arrays.arraysEqual(before, value)) {
      return false;
    }

    objects.setKeyToValue(key, value, context);

    const updated = this.resolve();
    if (typeof before === 'undefined') {
      emit(this, CONTEXT_VALUE_ADDED, key, value, local, updated);
    } else {
      emit(this, CONTEXT_VALUE_CHANGED, key, value, before, local, updated);
    }
    emit(this, CONTEXT_CHANGED, updated);
    return true;
  };

  /**
   * Merge the specified object into the current context.
   *
   * Note: This will cause the effective genome to be recomputed.
   *
   * @param {Object} update The values to update the context with.
   * @param {Boolean} [local = false] If true, the values will only be added to the localContext.
   */
  this.update = function(update, local) {
    if (Object.keys(update).length === 0 && update.constructor === Object) {
      // We will deprecate this at some point.
      console.warn('[Deprecation] Calling evolv.context.update({}) to reapply variants has been deprecated. Please use \'evolv.rerun()\' instead.');
      store.clearActiveKeys();
    }

    ensureInitialized();
    let context = local ? localContext : remoteContext;
    const flattened = objects.flatten(update);
    const flattenedBefore = {};
    Object.keys(flattened).forEach(function(key) {
      flattenedBefore[key] = context[key];
    });

    if (local) {
      localContext = objects.deepMerge(localContext, update);
      context = localContext;
    } else {
      remoteContext = objects.deepMerge(remoteContext, update);
      context = remoteContext;
    }

    const thisRef = this;
    const updated = this.resolve();
    Object.keys(flattened).forEach(function(key) {
      if (typeof flattenedBefore[key] === 'undefined') {
        emit(thisRef, CONTEXT_VALUE_ADDED, key, flattened[key], local, updated);
      } else if (flattenedBefore[key] !== context[key]) {
        emit(thisRef, CONTEXT_VALUE_CHANGED, key, flattened[key], flattenedBefore[key], local, updated);
      }
    });
    emit(this, CONTEXT_CHANGED, updated);
  };

  /**
   * Remove a specified key from the context.
   *
   * Note: This will cause the effective genome to be recomputed.
   *
   * @param key {String} The key to remove from the context.
   * @return boolean
   */
  this.remove = function(key) {
    ensureInitialized();
    const local = objects.removeValueForKey(key, localContext);
    const remote = objects.removeValueForKey(key, remoteContext);
    const removed = local || remote;

    if (removed) {
      const updated = this.resolve();
      emit(this, CONTEXT_VALUE_REMOVED, key, !remote, updated);
      emit(this, CONTEXT_CHANGED, updated);
    }

    return removed;
  };

  /**
   * Retrieve a value from the context.
   *
   * @param {String} key The key associated with the value to retrieve.
   * @returns {*} The value associated with the specified key.
   */
  this.get = function(key) {
    ensureInitialized();

    // Remove me when 'confirmations' and 'contaminations' are no longer set
    if (key === 'confirmations' || key === 'contaminations') {
      console.warn('[Deprecation] Retrieving confirmations and contaminations from the Evolv context with keys "confirmations"',
       ' and "contaminations" is deprecated. Please use "experiments.confirmations" and "experiments.contaminations" instead.');
    }

    const valueFromRemote = objects.getValueForKey(key, remoteContext);

    return objects.hasKey(key, remoteContext)
      ? valueFromRemote
      : objects.getValueForKey(key, localContext);
  };

  /**
   * Checks if the specified key is currently defined in the context.
   *
   * @param key The key to check.
   * @returns {boolean} True if the key has an associated value in the context.
   */
  this.contains = function(key) {
    ensureInitialized();
    return key in remoteContext || key in localContext;
  };

  /**
   * Adds value to specified array in context. If array doesn't exist its created and added to.
   *
   * @param {String} key The array to add to.
   * @param {*} value Value to add to the array.
   * @param {Boolean} [local = false] If true, the value will only be added to the localContext.
   * @param {Number} [limit] Max length of array to maintain.
   * @returns {boolean} True if value was successfully added.
   */
  this.pushToArray = function(key, value, local, limit) {
    limit = limit || DEFAULT_QUEUE_LIMIT;

    ensureInitialized();

    const context = local ? localContext : remoteContext;
    const originalArray = objects.getValueForKey(key, context);

    const combined = (originalArray || []).concat([value]);
    const newArray = combined.slice(combined.length - limit);

    return this.set(key, newArray, local);
  }
}

export default EvolvContext;