'use strict';

/**
 * @ngdoc widget
 * @name angular.widget.select
 *
 * @description
 * HTML `SELECT` element with angular data-binding.
 *
 * # `ng:options`
 *
 * Optionally `ng:options` attribute can be used to dynamically generate a list of `<option>`
 * elements for a `<select>` element using an array or an object obtained by evaluating the
 * `ng:options` expression.
 *
 * When an item in the select menu is select, the value of array element or object property
 * represented by the selected option will be bound to the model identified by the `name` attribute
 * of the parent select element.
 *
 * Optionally, a single hard-coded `<option>` element, with the value set to an empty string, can
 * be nested into the `<select>` element. This element will then represent `null` or "not selected"
 * option. See example below for demonstration.
 *
 * Note: `ng:options` provides iterator facility for `<option>` element which must be used instead
 * of {@link angular.widget.@ng:repeat ng:repeat}. `ng:repeat` is not suitable for use with
 * `<option>` element because of the following reasons:
 *
 *   * value attribute of the option element that we need to bind to requires a string, but the
 *     source of data for the iteration might be in a form of array containing objects instead of
 *     strings
 *   * {@link angular.widget.@ng:repeat ng:repeat} unrolls after the select binds causing
 *     incorect rendering on most browsers.
 *   * binding to a value not in list confuses most browsers.
 *
 * @param {string} name assignable expression to data-bind to.
 * @param {string=} required The widget is considered valid only if value is entered.
 * @param {comprehension_expression=} ng:options in one of the following forms:
 *
 *   * for array data sources:
 *     * `label` **`for`** `value` **`in`** `array`
 *     * `select` **`as`** `label` **`for`** `value` **`in`** `array`
 *     * `label`  **`group by`** `group` **`for`** `value` **`in`** `array`
 *     * `select` **`as`** `label` **`group by`** `group` **`for`** `value` **`in`** `array`
 *   * for object data sources:
 *     * `label` **`for (`**`key` **`,`** `value`**`) in`** `object`
 *     * `select` **`as`** `label` **`for (`**`key` **`,`** `value`**`) in`** `object`
 *     * `label` **`group by`** `group` **`for (`**`key`**`,`** `value`**`) in`** `object`
 *     * `select` **`as`** `label` **`group by`** `group`
 *         **`for` `(`**`key`**`,`** `value`**`) in`** `object`
 *
 * Where:
 *
 *   * `array` / `object`: an expression which evaluates to an array / object to iterate over.
 *   * `value`: local variable which will refer to each item in the `array` or each property value
 *      of `object` during iteration.
 *   * `key`: local variable which will refer to a property name in `object` during iteration.
 *   * `label`: The result of this expression will be the label for `<option>` element. The
 *     `expression` will most likely refer to the `value` variable (e.g. `value.propertyName`).
 *   * `select`: The result of this expression will be bound to the model of the parent `<select>`
 *      element. If not specified, `select` expression will default to `value`.
 *   * `group`: The result of this expression will be used to group options using the `<optgroup>`
 *      DOM element.
 *
 * @example
    <doc:example>
      <doc:source>
        <script>
        function MyCntrl() {
          this.colors = [
            {name:'black', shade:'dark'},
            {name:'white', shade:'light'},
            {name:'red', shade:'dark'},
            {name:'blue', shade:'dark'},
            {name:'yellow', shade:'light'}
          ];
          this.color = this.colors[2]; // red
        }
        </script>
        <div ng:controller="MyCntrl">
          <ul>
            <li ng:repeat="color in colors">
              Name: <input ng:model="color.name">
              [<a href ng:click="colors.$remove(color)">X</a>]
            </li>
            <li>
              [<a href ng:click="colors.push({})">add</a>]
            </li>
          </ul>
          <hr/>
          Color (null not allowed):
          <select ng:model="color" ng:options="c.name for c in colors"></select><br>

          Color (null allowed):
          <div  class="nullable">
            <select ng:model="color" ng:options="c.name for c in colors">
              <option value="">-- chose color --</option>
            </select>
          </div><br/>

          Color grouped by shade:
          <select ng:model="color" ng:options="c.name group by c.shade for c in colors">
          </select><br/>


          Select <a href ng:click="color={name:'not in list'}">bogus</a>.<br>
          <hr/>
          Currently selected: {{ {selected_color:color}  }}
          <div style="border:solid 1px black; height:20px"
               ng:style="{'background-color':color.name}">
          </div>
        </div>
      </doc:source>
      <doc:scenario>
         it('should check ng:options', function() {
           expect(binding('color')).toMatch('red');
           select('color').option('0');
           expect(binding('color')).toMatch('black');
           using('.nullable').select('color').option('');
           expect(binding('color')).toMatch('null');
         });
      </doc:scenario>
    </doc:example>
 */


                       //00001111100000000000222200000000000000000000003333000000000000044444444444444444000000000555555555555555550000000666666666666666660000000000000007777
var NG_OPTIONS_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w\d]*)|(?:\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*)$/;


angularWidget('select', function(element){
  this.directives(true);
  this.descend(true);
  return element.attr('ng:model') && annotate('$formFactory', function($formFactory, selectElement){
    var modelScope = this,
        match,
        form = $formFactory.forElement(selectElement),
        multiple = selectElement.attr('multiple'),
        optionsExp = selectElement.attr('ng:options'),
        modelExp = selectElement.attr('ng:model'),
        widget = form.$createWidget({
          scope: this,
          model: modelExp,
          onChange: selectElement.attr('ng:change'),
          alias: selectElement.attr('name'),
          controller: optionsExp ? Options : (multiple ? Multiple : Single)});

    selectElement.bind('$destroy', function() { widget.$destroy(); });

    widget.$pristine = !(widget.$dirty = false);

    watchElementProperty(modelScope, widget, 'required', selectElement);
    watchElementProperty(modelScope, widget, 'readonly', selectElement);
    watchElementProperty(modelScope, widget, 'disabled', selectElement);

    widget.$on('$validate', function() {
      var valid = !widget.$required || !!widget.$modelValue;
      if (valid && multiple && widget.$required) valid = !!widget.$modelValue.length;
      if (valid !== !widget.$error.REQUIRED) {
        widget.$emit(valid ? '$valid' : '$invalid', 'REQUIRED');
      }
    });

    widget.$on('$viewChange', function() {
      widget.$pristine = !(widget.$dirty = true);
    });

    forEach(['valid', 'invalid', 'pristine', 'dirty'], function(name) {
      widget.$watch('$' + name, function(scope, value) {
        selectElement[value ? 'addClass' : 'removeClass']('ng-' + name);
      });
    });

    ////////////////////////////

    function Multiple() {
      var widget = this;

      this.$render = function() {
        var items = new HashMap(this.$viewValue);
        forEach(selectElement.children(), function(option){
          option.selected = isDefined(items.get(option.value));
        });
      };

      selectElement.bind('change', function() {
        widget.$apply(function() {
          var array = [];
          forEach(selectElement.children(), function(option){
            if (option.selected) {
              array.push(option.value);
            }
          });
          widget.$emit('$viewChange', array);
        });
      });

    }

    function Single() {
      var widget = this;

      widget.$render = function() {
        selectElement.val(widget.$viewValue);
      };

      selectElement.bind('change', function() {
        widget.$apply(function() {
          widget.$emit('$viewChange', selectElement.val());
        });
      });

      widget.$viewValue = selectElement.val();
    }

    function Options() {
      var widget = this,
          match;

      if (! (match = optionsExp.match(NG_OPTIONS_REGEXP))) {
        throw Error(
          "Expected ng:options in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_'" +
          " but got '" + optionsExp + "'.");
      }

      var widgetScope = this,
          displayFn = expressionCompile(match[2] || match[1]),
          valueName = match[4] || match[6],
          keyName = match[5],
          groupByFn = expressionCompile(match[3] || ''),
          valueFn = expressionCompile(match[2] ? match[1] : valueName),
          valuesFn = expressionCompile(match[7]),
          // we can't just jqLite('<option>') since jqLite is not smart enough
          // to create it in <select> and IE barfs otherwise.
          optionTemplate = jqLite(document.createElement('option')),
          optGroupTemplate = jqLite(document.createElement('optgroup')),
          nullOption = false, // if false then user will not be able to select it
          // This is an array of array of existing option groups in DOM. We try to reuse these if possible
          // optionGroupsCache[0] is the options with no option group
          // optionGroupsCache[?][0] is the parent: either the SELECT or OPTGROUP element
          optionGroupsCache = [[{element: selectElement, label:''}]];

      // find existing special options
      forEach(selectElement.children(), function(option) {
        if (option.value == '') {
          // developer declared null option, so user should be able to select it
          nullOption = jqLite(option).remove();
          // compile the element since there might be bindings in it
          compile(nullOption)(modelScope);
        }
      });
      selectElement.html(''); // clear contents

      selectElement.bind('change', function() {
        widgetScope.$apply(function() {
          var optionGroup,
              collection = valuesFn(modelScope) || [],
              key = selectElement.val(),
              tempScope = inherit(modelScope),
              value, optionElement, index, groupIndex, length, groupLength;

          if (multiple) {
            value = [];
            for (groupIndex = 0, groupLength = optionGroupsCache.length;
            groupIndex < groupLength;
            groupIndex++) {
              // list of options for that group. (first item has the parent)
              optionGroup = optionGroupsCache[groupIndex];

              for(index = 1, length = optionGroup.length; index < length; index++) {
                if ((optionElement = optionGroup[index].element)[0].selected) {
                  if (keyName) tempScope[keyName] = key;
                  tempScope[valueName] = collection[optionElement.val()];
                  value.push(valueFn(tempScope));
                }
              }
            }
          } else {
            if (key == '?') {
              value = undefined;
            } else if (key == ''){
              value = null;
            } else {
              tempScope[valueName] = collection[key];
              if (keyName) tempScope[keyName] = key;
              value = valueFn(tempScope);
            }
          }
          if (isDefined(value) && modelScope.$viewVal !== value) {
            widgetScope.$emit('$viewChange', value);
          }
        });
      });

      widgetScope.$watch(render);
      widgetScope.$render = render;

      function render() {
        var optionGroups = {'':[]}, // Temporary location for the option groups before we render them
            optionGroupNames = [''],
            optionGroupName,
            optionGroup,
            option,
            existingParent, existingOptions, existingOption,
            modelValue = widget.$modelValue,
            values = valuesFn(modelScope) || [],
            keys = keyName ? sortedKeys(values) : values,
            groupLength, length,
            groupIndex, index,
            optionScope = inherit(modelScope),
            selected,
            selectedSet = false, // nothing is selected yet
            lastElement,
            element;

        if (multiple) {
          selectedSet = new HashMap(modelValue);
        } else if (modelValue === null || nullOption) {
          // if we are not multiselect, and we are null then we have to add the nullOption
          optionGroups[''].push({selected:modelValue === null, id:'', label:''});
          selectedSet = true;
        }

        // We now build up the list of options we need (we merge later)
        for (index = 0; length = keys.length, index < length; index++) {
             optionScope[valueName] = values[keyName ? optionScope[keyName]=keys[index]:index];
             optionGroupName = groupByFn(optionScope) || '';
          if (!(optionGroup = optionGroups[optionGroupName])) {
            optionGroup = optionGroups[optionGroupName] = [];
            optionGroupNames.push(optionGroupName);
          }
          if (multiple) {
            selected = selectedSet.remove(valueFn(optionScope)) != undefined;
          } else {
            selected = modelValue === valueFn(optionScope);
            selectedSet = selectedSet || selected; // see if at least one item is selected
          }
          optionGroup.push({
            id: keyName ? keys[index] : index,   // either the index into array or key from object
            label: displayFn(optionScope) || '', // what will be seen by the user
            selected: selected                   // determine if we should be selected
          });
        }
        if (!multiple && !selectedSet) {
          // nothing was selected, we have to insert the undefined item
          optionGroups[''].unshift({id:'?', label:'', selected:true});
        }

        // Now we need to update the list of DOM nodes to match the optionGroups we computed above
        for (groupIndex = 0, groupLength = optionGroupNames.length;
             groupIndex < groupLength;
             groupIndex++) {
          // current option group name or '' if no group
          optionGroupName = optionGroupNames[groupIndex];

          // list of options for that group. (first item has the parent)
          optionGroup = optionGroups[optionGroupName];

          if (optionGroupsCache.length <= groupIndex) {
            // we need to grow the optionGroups
            existingParent = {
              element: optGroupTemplate.clone().attr('label', optionGroupName),
              label: optionGroup.label
            };
            existingOptions = [existingParent];
            optionGroupsCache.push(existingOptions);
            selectElement.append(existingParent.element);
          } else {
            existingOptions = optionGroupsCache[groupIndex];
            existingParent = existingOptions[0];  // either SELECT (no group) or OPTGROUP element

            // update the OPTGROUP label if not the same.
            if (existingParent.label != optionGroupName) {
              existingParent.element.attr('label', existingParent.label = optionGroupName);
            }
          }

          lastElement = null;  // start at the begining
          for(index = 0, length = optionGroup.length; index < length; index++) {
            option = optionGroup[index];
            if ((existingOption = existingOptions[index+1])) {
              // reuse elements
              lastElement = existingOption.element;
              if (existingOption.label !== option.label) {
                lastElement.text(existingOption.label = option.label);
              }
              if (existingOption.id !== option.id) {
                lastElement.val(existingOption.id = option.id);
              }
              if (existingOption.element.selected !== option.selected) {
                lastElement.prop('selected', (existingOption.selected = option.selected));
              }
            } else {
              // grow elements

              // if it's a null option
              if (option.id === '' && nullOption) {
                // put back the pre-compiled element
                element = nullOption;
              } else {
                // jQuery(v1.4.2) Bug: We should be able to chain the method calls, but
                // in this version of jQuery on some browser the .text() returns a string
                // rather then the element.
                (element = optionTemplate.clone())
                    .val(option.id)
                    .attr('selected', option.selected)
                    .text(option.label);
              }

              existingOptions.push(existingOption = {
                  element: element,
                  label: option.label,
                  id: option.id,
                  selected: option.selected
              });
              if (lastElement) {
                lastElement.after(element);
              } else {
                existingParent.element.append(element);
              }
              lastElement = element;
            }
          }
          // remove any excessive OPTIONs in a group
          index++; // increment since the existingOptions[0] is parent element not OPTION
          while(existingOptions.length > index) {
            existingOptions.pop().element.remove();
          }
        }
        // remove any excessive OPTGROUPs from select
        while(optionGroupsCache.length > groupIndex) {
          optionGroupsCache.pop()[0].element.remove();
        }
      };
    }
  });
});
