// # ngReact
// ### Use React Components inside of your Angular applications
//
// Composed of
// - reactComponent (generic directive for delegating off to React Components)
// - reactDirective (factory for creating specific directives that correspond to reactComponent directives)


(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) {
    // CommonJS
    module.exports = factory(require('react'), require('react-dom'), require('angular'));
  } else if (typeof define === 'function' && define.amd) {
    // AMD
    define(['react', 'react-dom', 'angular'], function (react, reactDOM, angular) {
      return (root.ngReact = factory(react, reactDOM, angular));
    });
  } else {
    // Global Variables
    root.ngReact = factory(root.React, root.ReactDOM, root.angular);
  }
}(this, function ngReact(React, ReactDOM, angular) {
  'use strict';

  // get a react component from name (components can be an angular injectable e.g. value, factory or
  // available on window
  function getReactComponent( name, $injector ) {
    // if name is a function assume it is component and return it
    if (angular.isFunction(name)) {
      return name;
    }

    // a React component name must be specified
    if (!name) {
      throw new Error('ReactComponent name attribute must be specified');
    }

    // ensure the specified React component is accessible, and fail fast if it's not
    var reactComponent;
    try {
      reactComponent = $injector.get(name);
    } catch(e) { }

    if (!reactComponent) {
      try {
        reactComponent = name.split('.').reduce(function(current, namePart) {
          return current[namePart];
        }, window);
      } catch (e) { }
    }

    if (!reactComponent) {
      throw Error('Cannot find react component ' + name);
    }

    return reactComponent;
  }

  // wraps a function with scope.$apply, if already applied just return
  function applied(fn, scope) {
    if (fn.wrappedInApply) {
      return fn;
    }
    var wrapped = function() {
      var args = arguments;
      var phase = scope.$root.$$phase;
      if (phase === "$apply" || phase === "$digest") {
        return fn.apply(null, args);
      } else {
        return scope.$apply(function() {
          return fn.apply( null, args );
        });
      }
    };
    wrapped.wrappedInApply = true;
    return wrapped;
  }

  /**
   * wraps functions on obj in scope.$apply
   *
   * keeps backwards compatibility, as if propsConfig is not passed, it will
   * work as before, wrapping all functions and won't wrap only when specified.
   *
   * @version 0.4.1
   * @param obj react component props
   * @param scope current scope
   * @param propsConfig configuration object for all properties
   * @returns {Object} props with the functions wrapped in scope.$apply
   */
  function applyFunctions(obj, scope, propsConfig) {
    return Object.keys(obj || {}).reduce(function(prev, key) {
      var value = obj[key];
      var config = (propsConfig || {})[key] || {};
      /**
       * wrap functions in a function that ensures they are scope.$applied
       * ensures that when function is called from a React component
       * the Angular digest cycle is run
       */
      prev[key] = angular.isFunction(value) && config.wrapApply !== false
        ? applied(value, scope)
        : value;

      return prev;
    }, {});
  }

  /**
   *
   * @param watchDepth (value of HTML watch-depth attribute)
   * @param scope (angular scope)
   *
   * Uses the watchDepth attribute to determine how to watch props on scope.
   * If watchDepth attribute is NOT reference or collection, watchDepth defaults to deep watching by value
   */
  function watchProps (watchDepth, scope, watchExpressions, listener){
    var supportsWatchCollection = angular.isFunction(scope.$watchCollection);
    var supportsWatchGroup = angular.isFunction(scope.$watchGroup);

    var watchGroupExpressions = [];
    watchExpressions.forEach(function(expr){
      var actualExpr = getPropExpression(expr);
      var exprWatchDepth = getPropWatchDepth(watchDepth, expr);

      if (exprWatchDepth === 'collection' && supportsWatchCollection) {
        scope.$watchCollection(actualExpr, listener);
      } else if (exprWatchDepth === 'reference' && supportsWatchGroup) {
        watchGroupExpressions.push(actualExpr);
      } else {
        scope.$watch(actualExpr, listener, (exprWatchDepth !== 'reference'));
      }
    });

    if (watchGroupExpressions.length) {
      scope.$watchGroup(watchGroupExpressions, listener);
    }
  }

  // render React component, with scope[attrs.props] being passed in as the component props
  function renderComponent(component, props, scope, elem) {
    scope.$evalAsync(function() {
      ReactDOM.render(React.createElement(component, props), elem[0]);
    });
  }

  // get prop name from prop (string or array)
  function getPropName(prop) {
    return (Array.isArray(prop)) ? prop[0] : prop;
  }

  // get prop name from prop (string or array)
  function getPropConfig(prop) {
    return (Array.isArray(prop)) ? prop[1] : {};
  }

  // get prop expression from prop (string or array)
  function getPropExpression(prop) {
    return (Array.isArray(prop)) ? prop[0] : prop;
  }

  // find the normalized attribute knowing that React props accept any type of capitalization
  function findAttribute(attrs, propName) {
    var index = Object.keys(attrs).filter(function (attr) {
      return attr.toLowerCase() === propName.toLowerCase();
    })[0];
    return attrs[index];
  }

  // get watch depth of prop (string or array)
  function getPropWatchDepth(defaultWatch, prop) {
    var customWatchDepth = (
      Array.isArray(prop) &&
      angular.isObject(prop[1]) &&
      prop[1].watchDepth
    );
    return customWatchDepth || defaultWatch;
  }

  // # reactComponent
  // Directive that allows React components to be used in Angular templates.
  //
  // Usage:
  //     <react-component name="Hello" props="name"/>
  //
  // This requires that there exists an injectable or globally available 'Hello' React component.
  // The 'props' attribute is optional and is passed to the component.
  //
  // The following would would create and register the component:
  //
  //     var module = angular.module('ace.react.components');
  //     module.value('Hello', React.createClass({
  //         render: function() {
  //             return <div>Hello {this.props.name}</div>;
  //         }
  //     }));
  //
  var reactComponent = function($injector) {
    return {
      restrict: 'E',
      replace: true,
      link: function(scope, elem, attrs) {
        var reactComponent = getReactComponent(attrs.name, $injector);

        var renderMyComponent = function() {
          var scopeProps = scope.$eval(attrs.props);
          var props = applyFunctions(scopeProps, scope);

          renderComponent(reactComponent, props, scope, elem);
        };

        // If there are props, re-render when they change
        attrs.props ?
          watchProps(attrs.watchDepth, scope, [attrs.props], renderMyComponent) :
          renderMyComponent();

        // cleanup when scope is destroyed
        scope.$on('$destroy', function() {
          if (!attrs.onScopeDestroy) {
            ReactDOM.unmountComponentAtNode(elem[0]);
          } else {
            scope.$eval(attrs.onScopeDestroy, {
              unmountComponent: ReactDOM.unmountComponentAtNode.bind(this, elem[0])
            });
          }
        });
      }
    };
  };

  // # reactDirective
  // Factory function to create directives for React components.
  //
  // With a component like this:
  //
  //     var module = angular.module('ace.react.components');
  //     module.value('Hello', React.createClass({
  //         render: function() {
  //             return <div>Hello {this.props.name}</div>;
  //         }
  //     }));
  //
  // A directive can be created and registered with:
  //
  //     module.directive('hello', function(reactDirective) {
  //         return reactDirective('Hello', ['name']);
  //     });
  //
  // Where the first argument is the injectable or globally accessible name of the React component
  // and the second argument is an array of property names to be watched and passed to the React component
  // as props.
  //
  // This directive can then be used like this:
  //
  //     <hello name="name"/>
  //
  var reactDirective = function($injector) {
    return function(reactComponentName, props, conf, injectableProps) {
      var directive = {
        restrict: 'E',
        replace: true,
        link: function(scope, elem, attrs) {
          var reactComponent = getReactComponent(reactComponentName, $injector);

          // if props is not defined, fall back to use the React component's propTypes if present
          props = props || Object.keys(reactComponent.propTypes || {});
          if (!props.length) {
            var ngAttrNames = [];
            angular.forEach(attrs.$attr, function (value, key) {
              ngAttrNames.push(key);
            });
            props = ngAttrNames;
          }

          // for each of the properties, get their scope value and set it to scope.props
          var renderMyComponent = function() {
            var scopeProps = {}, config = {};
            props.forEach(function(prop) {
              var propName = getPropName(prop);
              scopeProps[propName] = scope.$eval(findAttribute(attrs, propName));
              config[propName] = getPropConfig(prop);
            });
            scopeProps = applyFunctions(scopeProps, scope, config);
            scopeProps = angular.extend({}, scopeProps, injectableProps);
            renderComponent(reactComponent, scopeProps, scope, elem);
          };

          // watch each property name and trigger an update whenever something changes,
          // to update scope.props with new values
          var propExpressions = props.map(function(prop){
            return (Array.isArray(prop)) ?
              [attrs[getPropName(prop)], getPropConfig(prop)] :
              attrs[prop];
          });

          watchProps(attrs.watchDepth, scope, propExpressions, renderMyComponent);

          renderMyComponent();

          // cleanup when scope is destroyed
          scope.$on('$destroy', function() {
            if (!attrs.onScopeDestroy) {
              ReactDOM.unmountComponentAtNode(elem[0]);
            } else {
              scope.$eval(attrs.onScopeDestroy, {
                unmountComponent: ReactDOM.unmountComponentAtNode.bind(this, elem[0])
              });
            }
          });
        }
      };
      return angular.extend(directive, conf);
    };
  };

  // create the end module without any dependencies, including reactComponent and reactDirective
  return angular.module('react', [])
    .directive('reactComponent', ['$injector', reactComponent])
    .factory('reactDirective', ['$injector', reactDirective]);
}));
