/*!
 * ReactFire is an open-source JavaScript library that allows you to add a
 * realtime data source to your React apps by providing and easy way to let
 * Firebase populate the state of React components.
 *
 * ReactFire 0.5.0
 * https://github.com/firebase/reactfire/
 * License: MIT
 */
/* eslint "strict": [2, "function"] */
(function(root, factory) {
  'use strict';

  /* istanbul ignore next */
  if (typeof define === 'function' && define.amd) {
    // AMD
    define([], function() {
      return (root.ReactFireMixin = factory());
    });
  } else if (typeof exports === 'object') {
    // CommonJS
    module.exports = factory();
  } else {
    // Global variables
    root.ReactFireMixin = factory();
  }
}(this, function() {
  'use strict';

  /*************/
  /*  HELPERS  */
  /*************/
  /**
   * Returns the index of the key in the list. If an item with the key is not in the list, -1 is
   * returned.
   *
   * @param {Array<any>} list A list of items.
   * @param {string} key The key for which to search.
   * @return {number} The index of the item which has the provided key or -1 if no items have the
   * provided key.
   */
  function _indexForKey(list, key) {
    for (var i = 0, length = list.length; i < length; ++i) {
      if (list[i]['.key'] === key) {
        return i;
      }
    }

    /* istanbul ignore next */
    return -1;
  }

  /**
   * Throws a formatted error message.
   *
   * @param {string} message The error message to throw.
   */
  function _throwError(message) {
    throw new Error('ReactFire: ' + message);
  }

  /**
   * Validates the name of the variable which is being bound.
   *
   * @param {string} bindVar The variable which is being bound.
   */
  function _validateBindVar(bindVar) {
    var errorMessage;

    if (typeof bindVar !== 'string') {
      errorMessage = 'Bind variable must be a string. Got: ' + bindVar;
    } else if (bindVar.length === 0) {
      errorMessage = 'Bind variable must be a non-empty string. Got: ""';
    } else if (bindVar.length > 768) {
      // Firebase can only stored child paths up to 768 characters
      errorMessage = 'Bind variable is too long to be stored in Firebase. Got: ' + bindVar;
    } else if (/[\[\].#$\/\u0000-\u001F\u007F]/.test(bindVar)) {
      // Firebase does not allow node keys to contain the following characters
      errorMessage = 'Bind variable cannot contain any of the following characters: . # $ ] [ /. Got: ' + bindVar;
    }

    if (typeof errorMessage !== 'undefined') {
      _throwError(errorMessage);
    }
  }

  /**
   * Creates a new record given a key-value pair.
   *
   * @param {string} key The new record's key.
   * @param {any} value The new record's value.
   * @return {Object} The new record.
   */
  function _createRecord(key, value) {
    var record = {};
    if (typeof value === 'object' && value !== null) {
      record = value;
    } else {
      record['.value'] = value;
    }
    record['.key'] = key;

    return record;
  }


  /******************************/
  /*  BIND AS OBJECT LISTENERS  */
  /******************************/
  /**
   * 'value' listener which updates the value of the bound state variable.
   *
   * @param {string} bindVar The state variable to which the data is being bound.
   * @param {Firebase.DataSnapshot} snapshot A snapshot of the data being bound.
   */
  function _objectValue(bindVar, snapshot) {
    var key = snapshot.key();
    var value = snapshot.val();

    this.data[bindVar] = _createRecord(key, value);

    this.setState(this.data);
  }


  /*****************************/
  /*  BIND AS ARRAY LISTENERS  */
  /*****************************/
  /**
   * 'child_added' listener which adds a new record to the bound array.
   *
   * @param {string} bindVar The state variable to which the data is being bound.
   * @param {Firebase.DataSnapshot} snapshot A snapshot of the data being bound.
   * @param {string|null} previousChildKey The key of the child after which the provided snapshot
   * is positioned; null if the provided snapshot is in the first position.
   */
  function _arrayChildAdded(bindVar, snapshot, previousChildKey) {
    var key = snapshot.key();
    var value = snapshot.val();
    var array = this.data[bindVar];

    // Determine where to insert the new record
    var insertionIndex;
    if (previousChildKey === null) {
      insertionIndex = 0;
    } else {
      var previousChildIndex = _indexForKey(array, previousChildKey);
      insertionIndex = previousChildIndex + 1;
    }

    // Add the new record to the array
    array.splice(insertionIndex, 0, _createRecord(key, value));

    // Update state
    this.setState(this.data);
  }

  /**
   * 'child_removed' listener which removes a record from the bound array.
   *
   * @param {string} bindVar The state variable to which the data is bound.
   * @param {Firebase.DataSnapshot} snapshot A snapshot of the bound data.
   */
  function _arrayChildRemoved(bindVar, snapshot) {
    var array = this.data[bindVar];

    // Look up the record's index in the array
    var index = _indexForKey(array, snapshot.key());

    // Splice out the record from the array
    array.splice(index, 1);

    // Update state
    this.setState(this.data);
  }

  /**
   * 'child_changed' listener which updates a record's value in the bound array.
   *
   * @param {string} bindVar The state variable to which the data is bound.
   * @param {Firebase.DataSnapshot} snapshot A snapshot of the data to bind.
   */
  function _arrayChildChanged(bindVar, snapshot) {
    var key = snapshot.key();
    var value = snapshot.val();
    var array = this.data[bindVar];

    // Look up the record's index in the array
    var index = _indexForKey(array, key);

    // Update the record's value in the array
    array[index] = _createRecord(key, value);

    // Update state
    this.setState(this.data);
  }

  /**
   * 'child_moved' listener which updates a record's position in the bound array.
   *
   * @param {string} bindVar The state variable to which the data is bound.
   * @param {Firebase.DataSnapshot} snapshot A snapshot of the bound data.
   * @param {string|null} previousChildKey The key of the child after which the provided snapshot
   * is positioned; null if the provided snapshot is in the first position.
   */
  function _arrayChildMoved(bindVar, snapshot, previousChildKey) {
    var key = snapshot.key();
    var array = this.data[bindVar];

    // Look up the record's index in the array
    var currentIndex = _indexForKey(array, key);

    // Splice out the record from the array
    var record = array.splice(currentIndex, 1)[0];

    // Determine where to re-insert the record
    var insertionIndex;
    if (previousChildKey === null) {
      insertionIndex = 0;
    } else {
      var previousChildIndex = _indexForKey(array, previousChildKey);
      insertionIndex = previousChildIndex + 1;
    }

    // Re-insert the record into the array
    array.splice(insertionIndex, 0, record);

    // Update state
    this.setState(this.data);
  }


  /*************/
  /*  BINDING  */
  /*************/
  /**
   * Creates a binding between Firebase and the inputted bind variable as either an array or
   * an object.
   *
   * @param {Firebase} firebaseRef The Firebase ref whose data to bind.
   * @param {string} bindVar The state variable to which to bind the data.
   * @param {function} cancelCallback The Firebase reference's cancel callback.
   * @param {boolean} bindAsArray Whether or not to bind as an array or object.
   */
  function _bind(firebaseRef, bindVar, cancelCallback, bindAsArray) {
    if (Object.prototype.toString.call(firebaseRef) !== '[object Object]') {
      _throwError('Invalid Firebase reference');
    }

    _validateBindVar(bindVar);

    if (typeof this.firebaseRefs[bindVar] !== 'undefined') {
      _throwError('this.state.' + bindVar + ' is already bound to a Firebase reference');
    }

    // Keep track of the Firebase reference we are setting up listeners on
    this.firebaseRefs[bindVar] = firebaseRef.ref();

    if (bindAsArray) {
      // Set initial state to an empty array
      this.data[bindVar] = [];
      this.setState(this.data);

      // Add listeners for all 'child_*' events
      this.firebaseListeners[bindVar] = {
        child_added: firebaseRef.on('child_added', _arrayChildAdded.bind(this, bindVar), cancelCallback),
        child_removed: firebaseRef.on('child_removed', _arrayChildRemoved.bind(this, bindVar), cancelCallback),
        child_changed: firebaseRef.on('child_changed', _arrayChildChanged.bind(this, bindVar), cancelCallback),
        child_moved: firebaseRef.on('child_moved', _arrayChildMoved.bind(this, bindVar), cancelCallback)
      };
    } else {
      // Add listener for 'value' event
      this.firebaseListeners[bindVar] = {
        value: firebaseRef.on('value', _objectValue.bind(this, bindVar), cancelCallback)
      };
    }
  }


  var ReactFireMixin = {
    /********************/
    /*  MIXIN LIFETIME  */
    /********************/
    /**
     * Initializes the Firebase refs and listeners arrays.
     **/
    componentWillMount: function() {
      this.data = {};
      this.firebaseRefs = {};
      this.firebaseListeners = {};
    },

    /**
     * Unbinds any remaining Firebase listeners.
     */
    componentWillUnmount: function() {
      for (var bindVar in this.firebaseRefs) {
        /* istanbul ignore else */
        if (this.firebaseRefs.hasOwnProperty(bindVar)) {
          this.unbind(bindVar);
        }
      }
    },


    /*************/
    /*  BINDING  */
    /*************/
    /**
     * Creates a binding between Firebase and the inputted bind variable as an array.
     *
     * @param {Firebase} firebaseRef The Firebase ref whose data to bind.
     * @param {string} bindVar The state variable to which to bind the data.
     * @param {function} cancelCallback The Firebase reference's cancel callback.
     */
    bindAsArray: function(firebaseRef, bindVar, cancelCallback) {
      var bindPartial = _bind.bind(this);
      bindPartial(firebaseRef, bindVar, cancelCallback, /* bindAsArray */ true);
    },

    /**
     * Creates a binding between Firebase and the inputted bind variable as an object.
     *
     * @param {Firebase} firebaseRef The Firebase ref whose data to bind.
     * @param {string} bindVar The state variable to which to bind the data.
     * @param {function} cancelCallback The Firebase reference's cancel callback.
     */
    bindAsObject: function(firebaseRef, bindVar, cancelCallback) {
      var bindPartial = _bind.bind(this);
      bindPartial(firebaseRef, bindVar, cancelCallback, /* bindAsArray */ false);
    },

    /**
     * Removes the binding between Firebase and the inputted bind variable.
     *
     * @param {string} bindVar The state variable to which the data is bound.
     * @param {function} callback Called when the data is unbound and the state has been updated.
     */
    unbind: function(bindVar, callback) {
      _validateBindVar(bindVar);

      if (typeof this.firebaseRefs[bindVar] === 'undefined') {
        _throwError('this.state.' + bindVar + ' is not bound to a Firebase reference');
      }

      // Turn off all Firebase listeners
      for (var event in this.firebaseListeners[bindVar]) {
        /* istanbul ignore else */
        if (this.firebaseListeners[bindVar].hasOwnProperty(event)) {
          var offListener = this.firebaseListeners[bindVar][event];
          this.firebaseRefs[bindVar].off(event, offListener);
        }
      }
      this.firebaseRefs[bindVar] = undefined;
      this.firebaseListeners[bindVar] = undefined;

      // Update state
      var newState = {};
      newState[bindVar] = undefined;
      this.setState(newState, callback);
    }
  };

  return ReactFireMixin;
}));
