/*!
 * <%= pkg.title %> v<%= pkg.version %>
 *
 * <%= pkg.description %>
 *
 * <%= pkg.homepage %>
 *
 * Copyright <%= grunt.template.today('yyyy') %> by <%= pkg.author.name %>
 * Licensed <%= _.pluck(pkg.licenses, 'type').join(', ') %>
 */
(function (factory) {
  'use strict';

  // Register as an AMD module, compatible with script loaders like RequireJS.
  // Source: https://github.com/umdjs/umd/blob/master/jqueryPlugin.js
  if (typeof define === 'function' && define.amd) {
    define(['jquery'], factory);
  }
  else {
    factory(jQuery);
  }
}(function ($, undefined) {
  'use strict';

  // The cache spans all instances and is indexed by URL. This allows different
  // instances to pull the same cached results if their URLs match.
  var cache = {};

  // jQuery UI's Widget Factory provides an object-oriented plugin framework
  // that handles the common plumbing tasks.
  $.widget('mp.marcoPolo', {
    // Default options.
    options: {
      // Whether to cache query results.
      cache: true,
      // Whether to compare the selected item against items displayed in the
      // results list. The selected item is highlighted if a match is found,
      // instead of the first item in the list ('highlight' option must be
      // enabled). Set this option to 'true' if the data is a string;
      // otherwise, specify the data object attribute name to compare on.
      compare: false,
      // Additional data to be sent in the request query string.
      data: {},
      // The number of milliseconds to delay before firing a request after a
      // change is made to the input value.
      delay: 250,
      // Format the raw data that's returned from the ajax request. Useful for
      // further filtering the data or returning the array of results that's
      // embedded deeper in the object.
      formatData: null,
      // Format the text that's displayed when the ajax request fails. Setting
      // this option to 'null' or returning 'false' suppresses the message from
      // being displayed.
      formatError: function () {
        return '<em>Your search could not be completed at this time.</em>';
      },
      // Format the display of each item in the results list.
      formatItem: function (data) {
        return data.title || data.name;
      },
      // Format the text that's displayed when the minimum number of characters
      // (specified with the 'minChars' option) hasn't been reached. Setting
      // this option to 'null' or returning 'false' suppresses the message from
      // being displayed.
      formatMinChars: function (minChars) {
        return '<em>Your search must be at least <strong>' + minChars + '</strong> characters.</em>';
      },
      // Format the text that's displayed when there are no results returned
      // for the requested input value. Setting this option to 'null' or
      // returning 'false' suppresses the message from being displayed.
      formatNoResults: function (q) {
        return '<em>No results for <strong>' + q + '</strong>.</em>';
      },
      // Whether to hide the results list when an item is selected. The results
      // list is still hidden when the input is blurred for any other reason.
      hideOnSelect: true,
      // Whether to automatically highlight an item when the results list is
      // displayed. Usually it's the first item, but it could be the previously
      // selected item if 'compare' is specified.
      highlight: true,
      // Positioning a label over an input is a common design pattern
      // (sometimes referred to as 'overlabel') that unfortunately doesn't
      // work so well with all of the input focus/blur events that occur with
      // autocomplete. With this option, however, the hiding/showing of the
      // label is handled internally to provide a built-in solution to the
      // problem.
      label: null,
      // The minimum number of characters required before a request is fired.
      minChars: 1,
      // Called when the user is finished interacting with the autocomplete
      // interface, not just the text input, which loses and gains focus on a
      // results list mouse click.
      onBlur: null,
      // Called when the input value changes.
      onChange: null,
      // Called when the ajax request fails.
      onError: null,
      // Called when the input field receives focus.
      onFocus: null,
      // Called when the minimum number of characters (specified with the
      // 'minChars' option) hasn't been reached by the end of the 'delay'.
      onMinChars: null,
      // Called when there are no results returned for the request.
      onNoResults: null,
      // Called before the ajax request is made.
      onRequestBefore: null,
      // Called after the ajax request completes (success or error).
      onRequestAfter: null,
      // Called when there are results to be displayed.
      onResults: null,
      // Called when an item is selected from the results list or passed in
      // through the 'selected' option.
      onSelect: function (data) {
        this.val(data.title || data.name);
      },
      // The name of the query string parameter that is set with the input
      // value.
      param: 'q',
      // Whether to clear the input value when no selection is made from the
      // results list.
      required: false,
      // The list items to make selectable.
      selectable: '*',
      // Prime the input with a selected item.
      selected: null,
      // Whether to allow the browser's default behavior of submitting the form
      // on ENTER.
      submitOnEnter: false,
      // The URL to GET request for the results.
      url: null
    },

    // Key code to key name mapping for easy reference.
    keys: {
      DOWN: 40,
      END: 35,
      ENTER: 13,
      ESC: 27,
      HOME: 36,
      TAB: 9,
      UP: 38
    },

    // Initialize the plugin on an input.
    _create: function () {
      var self = this,
          $input;

      // Create a more appropriately named alias for the input.
      self.$input = $input = self.element.addClass('mp_input');

      // The existing input name or a created one. Used for building the ID of
      // other elements.
      self.inputName = 'mp_' + ($input.attr('name') || $.now());

      // Create an empty list for displaying future results. Insert it directly
      // after the input element.
      self.$list = $('<ol class="mp_list" />')
                     .attr({
                       'aria-atomic': 'true',
                       'aria-busy': 'false',
                       'aria-live': 'polite',
                       'id': self.inputName + '_list',
                       'role': 'listbox'
                     })
                     .hide()
                     .insertAfter(self.$input);

      // Remember original input attribute values for when 'destroy' is called
      // and the input is returned to its original state.
      self.inputOriginals = {
        'aria-activedescendant': $input.attr('aria-activedescendant'),
        'aria-autocomplete': $input.attr('aria-autocomplete'),
        'aria-expanded': $input.attr('aria-expanded'),
        'aria-labelledby': $input.attr('aria-labelledby'),
        'aria-owns': $input.attr('aria-owns'),
        'aria-required': $input.attr('aria-required'),
        'autocomplete': $input.attr('autocomplete'),
        'role': $input.attr('role')
      };

      // Set plugin-specific attributes.
      $input.attr({
        'aria-autocomplete': 'list',
        'aria-owns': self.$list.attr('id'),
        'autocomplete': 'off',
        'role': 'combobox'
      });

      // The ajax request to get results is stored in case the request needs to
      // be aborted before a response is returned.
      self.ajax = null;
      self.ajaxAborted = false;

      // A reference to this function is maintained for unbinding in the
      // 'destroy' method. This is necessary because the selector is so
      // generic (document).
      self.documentMouseup = null;

      // "Pseudo" focus includes any interaction with the plugin, even if the
      // input has blurred.
      self.focusPseudo = false;

      // "Real" focus is strictly when the input has focus.
      self.focusReal = false;

      // Whether a mousedown event is triggered on a list item.
      self.mousedown = false;

      // The currently selected data.
      self.selectedData = null;

      // Whether the last selection was by mouseup.
      self.selectedMouseup = false;

      // The request buffer timer in case the timer needs to be aborted due to
      // another key press.
      self.timer = null;

      // The current input value for comparison.
      self.value = self.$input.val();

      // Bind the necessary events.
      self
        ._bindInput()
        ._bindList()
        ._bindDocument();

      self
        ._initSelected()
        ._initOptions();
    },

    // Set an option.
    _setOption: function (option, value) {
      // Required call to the parent where the new option value is saved.
      $.Widget.prototype._setOption.apply(this, arguments);

      this._initOptions(option, value);
    },

    // Initialize options that require a little extra work.
    _initOptions: function (option, value) {
      var self = this,
          allOptions = option === undefined,
          options = {};

      // If no option is specified, initialize all options.
      if (allOptions) {
        options = self.options;
      }
      // Otherwise, initialize only the specified option.
      else {
        options[option] = value;
      }

      $.each(options, function (option, value) {
        switch (option) {
          case 'label':
            // Ensure that the 'label' is a jQuery object if a selector string
            // or plain DOM element is passed.
            self.options.label = $(value).addClass('mp_label');

            // Ensure that the label has an ID for ARIA support.
            if (self.options.label.attr('id')) {
              self.removeLabelId = false;
            }
            else {
              self.removeLabelId = true;

              self.options.label.attr('id', self.inputName + '_label');
            }

            self._toggleLabel();

            self.$input.attr('aria-labelledby', self.options.label.attr('id'));

            break;

          case 'required':
            self.$input.attr('aria-required', value);

            break;

          case 'selected':
            // During initial creation (when all options are initialized), only
            // initialize the 'selected' value if there is one. The
            // '_initSelected' method parses the input's attributes for a
            // selected value.
            if (allOptions && value) {
              self.select(value, null, true);
            }

            break;

          case 'url':
            // If no 'url' option is specified, use the parent form's 'action'.
            if (!value) {
              self.options.url = self.$input.closest('form').attr('action');
            }

            break;
        }
      });

      return self;
    },

    // Programmatically change the input value without triggering a search
    // request (use the 'search' method for that). If the value is different
    // than the current input value, the 'onChange' callback is fired.
    change: function (q, onlyValue) {
      var self = this;

      // Change the input value if a new value is specified.
      if (q === self.value) {
        return;
      }

      if (q !== self.$input.val()) {
        self.$input.val(q);
      }

      // Reset the currently selected data.
      self.selectedData = null;

      // Keep track of the new input value for later comparison.
      self.value = q;

      self._trigger('change', [q]);

      if (onlyValue !== true) {
        if (self.focusPseudo) {
          // Clear out the existing results to prevent future stale results
          // in case the change is made while the input has focus.
          self
            ._cancelPendingRequest()
            ._hideAndEmptyList();
        }
        else {
          // Show or hide the label depending on if the input has a value.
          self._toggleLabel();
        }
      }
    },

    // Programmatically trigger a search request using the existing input value
    // or a new one.
    search: function (q) {
      var $input = this.$input;

      // Change the input value if a new value is specified. Otherwise, use the
      // existing input value.
      if (q !== undefined) {
        $input.val(q);
      }

      // Focus on the input to start the request and enable keyboard
      // navigation (only available when the input has focus).
      $input.focus();
    },

    // Select an item from the results list.
    select: function (data, $item, initial) {
      var self = this,
          $input = self.$input,
          hideOnSelect = self.options.hideOnSelect;

      if (hideOnSelect) {
        self._hideList();
      }

      // If there's no data, consider this a call to deselect (or reset) the
      // current value.
      if (!data) {
        return self.change('');
      }

      // Save the selected data for later reference.
      self.selectedData = data;

      self._trigger('select', [data, $item, !!initial]);

      // It's common to update the input value with the selected item during
      // 'onSelect', so check if that has occurred and store the new value.
      if ($input.val() !== self.value) {
        self.value = $input.val();

        // Check if the label needs to be toggled when this method is called
        // programmatically (usually meaning the input doesn't have focus).
        if (!self.focusPseudo) {
          self._toggleLabel();
        }

        // Hide and empty the existing results to prevent future stale results.
        self._hideAndEmptyList();
      }
    },

    // Initialize the input with a selected value from the 'data-selected'
    // attribute (JSON) or standard 'value' attribute (string).
    _initSelected: function () {
      var self = this,
          $input = self.$input,
          data = $input.data('selected'),
          value = $input.val();

      if (data) {
        self.select(data, null, true);
      }
      else if (value) {
        self.select(value, null, true);
      }

      return self;
    },

    // Get the currently selected data.
    selected: function () {
      return this.selectedData;
    },

    // Remove the autocomplete functionality and return the selected input
    // fields to their original state.
    destroy: function () {
      var self = this,
          options = self.options,
          $input = self.$input;

      // Remove the results list element.
      self.$list.remove();

      // Reset the input to its original attribute values.
      $.each(self.inputOriginals, function (attribute, value) {
        if (value === undefined) {
          $input.removeAttr(attribute);
        }
        else {
          $input.attr(attribute, value);
        }
      });

      $input.removeClass('mp_input');

      // Reset the label to its original state.
      if (options.label) {
        options.label.removeClass('mp_label');

        if (self.removeLabelId) {
          options.label.removeAttr('id');
        }
      }

      // Remove the specific document 'mouseup' event for this instance.
      $(document).unbind('mouseup.marcoPolo', self.documentMouseup);

      // Parent destroy removes the input's data and events.
      $.Widget.prototype.destroy.apply(self, arguments);
    },

    // Get the results list element.
    list: function () {
      return this.$list;
    },

    // Bind the necessary events to the input.
    _bindInput: function () {
      var self = this,
          $input = self.$input,
          $list = self.$list;

      $input
        .bind('focus.marcoPolo', function () {
          // Do nothing if the input already has focus. This prevents
          // additional 'focus' events from initiating the same request.
          if (self.focusReal) {
            return;
          }

          // It's overly complicated to check if an input field has focus, so
          // "manually" keep track in the 'focus' and 'blur' events.
          self.focusPseudo = true;
          self.focusReal = true;

          self._toggleLabel();

          // If this focus is the result of a mouse selection (which re-focuses
          // on the input), ignore as if a blur never occurred.
          if (self.selectedMouseup) {
            self.selectedMouseup = false;
          }
          // For everything else, initiate a request.
          else {
            self._trigger('focus');

            self._request($input.val());
          }
        })
        .bind('keydown.marcoPolo', function (key) {
          var $highlighted = $();

          switch (key.which) {
            // Highlight the previous item.
            case self.keys.UP:
              // The default moves the cursor to the beginning or end of the
              // input value. Keep it in its current place.
              key.preventDefault();

              // Show the list if it has been hidden by ESC.
              self
                ._showList()
                ._highlightPrev();

              break;

            // Highlight the next item.
            case self.keys.DOWN:
              // The default moves the cursor to the beginning or end of the
              // input value. Keep it in its current place.
              key.preventDefault();

              // Show the list if it has been hidden by ESC.
              self
                ._showList()
                ._highlightNext();

              break;

            // Highlight the first item.
            case self.keys.HOME:
              // Show the list if it has been hidden by ESC.
              self
                ._showList()
                ._highlightFirst();

              break;

            // Highlight the last item.
            case self.keys.END:
              // Show the list if it has been hidden by ESC.
              self
                ._showList()
                ._highlightLast();

              break;

            // Select the currently highlighted item. Input keeps focus.
            case self.keys.ENTER:
              // Prevent selection if the list isn't visible.
              if (!$list.is(':visible')) {
                // Prevent the form from submitting.
                if (!self.options.submitOnEnter) {
                  key.preventDefault();
                }

                return;
              }

              $highlighted = self._highlighted();

              if ($highlighted.length) {
                self.select($highlighted.data('marcoPolo'), $highlighted);
              }

              // Prevent the form from submitting if 'submitOnEnter' is
              // disabled or if there's a highlighted item.
              if (!self.options.submitOnEnter || $highlighted.length) {
                key.preventDefault();
              }

              break;

            // Select the currently highlighted item. Input loses focus.
            case self.keys.TAB:
              // Prevent selection if the list isn't visible.
              if (!$list.is(':visible')) {
                return;
              }

              $highlighted = self._highlighted();

              if ($highlighted.length) {
                self.select($highlighted.data('marcoPolo'), $highlighted);
              }

              break;

            // Hide the list.
            case self.keys.ESC:
              self
                ._cancelPendingRequest()
                ._hideList();

              break;
          }
        })
        .bind('keyup.marcoPolo', function () {
          // Check if the input value has changed. This prevents keys like CTRL
          // and SHIFT from firing a new request.
          if ($input.val() !== self.value) {
            self._request($input.val());
          }
        })
        .bind('blur.marcoPolo', function () {
          self.focusReal = false;

          // When an item in the results list is clicked, the input blur event
          // fires before the click event, causing the results list to become
          // hidden (code below). This 1ms timeout ensures that the click event
          // code fires before that happens.
          setTimeout(function () {
            // If the $list 'mousedown' event has fired without a 'mouseup'
            // event, wait for that before dismissing everything.
            if (!self.mousedown) {
              self._dismiss();
            }
          }, 1);
        });

      return self;
    },

    // Bind the necessary events to the list.
    _bindList: function () {
      var self = this;

      self.$list
        .bind('mousedown.marcoPolo', function () {
          // Tracked for use in the input 'blur' event.
          self.mousedown = true;
        })
        .delegate('li.mp_selectable', 'mouseover', function () {
          self._addHighlight($(this));
        })
        .delegate('li.mp_selectable', 'mouseout', function () {
          self._removeHighlight($(this));
        })
        .delegate('li.mp_selectable', 'mouseup', function () {
          var $item = $(this);

          self.select($item.data('marcoPolo'), $item);

          // This event is tracked so that when 'focus' is called on the input
          // (below), a new request isn't fired.
          self.selectedMouseup = true;

          // Give focus back to the input for easy tabbing on to the next
          // field.
          self.$input.focus();
        });

      return self;
    },

    // Bind the necessary events to the document.
    _bindDocument: function () {
      var self = this;

      // A reference to this function is maintained for unbinding in the
      // 'destroy' method. This is necessary because the selector is so
      // generic (document).
      $(document).bind('mouseup.marcoPolo', self.documentMouseup = function () {
        // Tracked for use in the input 'blur' event.
        self.mousedown = false;

        // Ensure that everything is dismissed if anything other than the input
        // is clicked. (A click on a selectable list item is handled above,
        // before this code fires.)
        if (!self.focusReal && self.$list.is(':visible')) {
          self._dismiss();
        }
      });

      return self;
    },

    // Show or hide the label (if one exists) depending on whether the input
    // has focus or a value.
    _toggleLabel: function () {
      var self = this,
          $label = self.options.label;

      if ($label && $label.length) {
        if (self.focusPseudo || self.$input.val()) {
          $label.hide();
        }
        else {
          $label.show();
        }
      }

      return self;
    },

    // Get the first selectable item in the results list.
    _firstSelectableItem: function () {
      return this.$list.children('li.mp_selectable:visible:first');
    },

    // Get the last selectable item in the results list.
    _lastSelectableItem: function () {
      return this.$list.children('li.mp_selectable:visible:last');
    },

    // Get the currently highlighted item in the results list.
    _highlighted: function () {
      return this.$list.children('li.mp_highlighted');
    },

    // Remove the highlight class from the specified item.
    _removeHighlight: function ($item) {
      $item
        .removeClass('mp_highlighted')
        .attr('aria-selected', 'false')
        .removeAttr('id');

      this.$input.removeAttr('aria-activedescendant');

      return this;
    },

    // Add the highlight class to the specified item.
    _addHighlight: function ($item) {
      // The current highlight is removed to ensure that only one item is
      // highlighted at a time.
      this._removeHighlight(this._highlighted());

      $item
        .addClass('mp_highlighted')
        .attr({
          'aria-selected': 'true',
          'id': this.inputName + '_highlighted'
        });

      this.$input.attr('aria-activedescendant', $item.attr('id'));

      return this;
    },

    // Highlight the first selectable item in the results list.
    _highlightFirst: function () {
      this._addHighlight(this._firstSelectableItem());

      return this;
    },

    // Highlight the last selectable item in the results list.
    _highlightLast: function () {
      this._addHighlight(this._lastSelectableItem());

      return this;
    },

    // Highlight the item before the currently highlighted item.
    _highlightPrev: function () {
      var $highlighted = this._highlighted(),
          $prev = $highlighted.prevAll('li.mp_selectable:visible:first');

      // If there is no "previous" selectable item, continue at the list's end.
      if (!$prev.length) {
        $prev = this._lastSelectableItem();
      }

      this._addHighlight($prev);

      return this;
    },

    // Highlight the item after the currently highlighted item.
    _highlightNext: function () {
      var $highlighted = this._highlighted(),
          $next = $highlighted.nextAll('li.mp_selectable:visible:first');

      // If there is no "next" selectable item, continue at the list's
      // beginning.
      if (!$next.length) {
        $next = this._firstSelectableItem();
      }

      this._addHighlight($next);

      return this;
    },

    // Show the results list.
    _showList: function () {
      // But only if there are results to be shown.
      if (this.$list.children().length) {
        this.$list.show();

        this.$input.attr('aria-expanded', 'true');
      }

      return this;
    },

    // Hide the results list.
    _hideList: function () {
      this.$list.hide();

      this.$input
        .removeAttr('aria-activedescendant')
        .removeAttr('aria-expanded');

      return this;
    },

    // Empty the results list.
    _emptyList: function () {
      this.$list.empty();

      this.$input.removeAttr('aria-activedescendant');

      return this;
    },

    // Hide and empty the results list.
    _hideAndEmptyList: function () {
      this.$list
        .hide()
        .empty();

      this.$input
        .removeAttr('aria-activedescendant')
        .removeAttr('aria-expanded');

      return this;
    },

    // Build the results list from a successful request that returned no data.
    _buildNoResultsList: function (q) {
      var self = this,
          $input = self.$input,
          $list = self.$list,
          options = self.options,
          $item = $('<li class="mp_no_results" role="alert" />'),
          formatNoResults;

      // Fire 'formatNoResults' callback.
      formatNoResults = options.formatNoResults && options.formatNoResults.call($input, q, $item);

      if (formatNoResults) {
        $item.html(formatNoResults);
      }

      self._trigger('noResults', [q, $item]);

      // Displaying a "no results" message is optional. It isn't displayed if
      // the 'formatNoResults' callback returns a false value.
      if (formatNoResults) {
        $item.appendTo($list);

        self._showList();
      }
      else {
        self._hideList();
      }

      return self;
    },

    // Build the results list from a successful request that returned data.
    _buildResultsList: function (q, data) {
      var self = this,
          $input = self.$input,
          $list = self.$list,
          options = self.options,
          // The currently selected data for use in comparison.
          selected = self.selectedData,
          // Whether to compare the currently selected item with the results. A
          // 'compare' setting key has to be specified, and there must be a
          // currently selected item.
          compare = options.compare && selected,
          compareCurrent,
          compareSelected,
          compareMatch = false,
          datum,
          $item = $(),
          formatItem;

      // Loop through each result and add it to the list.
      for (var i = 0, length = data.length; i < length; i++) {
        datum = data[i];
        $item = $('<li class="mp_item" />');
        formatItem = options.formatItem.call($input, datum, $item);

        // Store the original data for easy access later.
        $item.data('marcoPolo', datum);

        $item
          .html(formatItem)
          .appendTo($list);

        if (compare && options.highlight) {
          // If the 'compare' setting is set to boolean 'true', assume the data
          // is a string and compare directly.
          if (options.compare === true) {
            compareCurrent = datum;
            compareSelected = selected;
          }
          // Otherwise, assume the data is an object and the 'compare' setting
          // is the attribute name to compare on.
          else {
            compareCurrent = datum[options.compare];
            compareSelected = selected[options.compare];
          }

          // Highlight this item if it matches the selected item.
          if (compareCurrent === compareSelected) {
            self._addHighlight($item);

            // Stop comparing the remaining results, as a match has been made.
            compare = false;
            compareMatch = true;
          }
        }
      }

      // Mark all selectable items, based on the 'selectable' selector setting.
      $list
        .children(options.selectable)
        .addClass('mp_selectable')
        .attr({
          'aria-selected': 'false',
          'role': 'option'
        });

      self._trigger('results', [data]);

      self._showList();

      // Highlight the first item in the results list if the currently selected
      // item was not found and already highlighted, and the option to auto-
      // highlight is enabled.
      if (!compareMatch && options.highlight) {
        self._highlightFirst();
      }

      return self;
    },

    // Build the results list from a successful request.
    _buildSuccessList: function (q, data) {
      var self = this,
          $input = self.$input,
          options = self.options;

      self._emptyList();

      // Fire 'formatData' callback.
      if (options.formatData) {
        data = options.formatData.call($input, data);
      }

      if (!data || data.length === 0 || $.isEmptyObject(data)) {
        self._buildNoResultsList(q);
      }
      else {
        self._buildResultsList(q, data);
      }

      return self;
    },

    // Build the results list with an error message.
    _buildErrorList: function (jqXHR, textStatus, errorThrown) {
      var self = this,
          $input = self.$input,
          $list = self.$list,
          options = self.options,
          $item = $('<li class="mp_error" role="alert" />'),
          formatError;

      self._emptyList();

      // Fire 'formatError' callback.
      formatError = options.formatError && options.formatError.call($input, $item, jqXHR, textStatus, errorThrown);

      if (formatError) {
        $item.html(formatError);
      }

      self._trigger('error', [$item, jqXHR, textStatus, errorThrown]);

      // Displaying an error message is optional. It isn't displayed if the
      // 'formatError' callback returns a false value.
      if (formatError) {
        $item.appendTo($list);

        self._showList();
      }
      else {
        self._hideList();
      }

      return self;
    },

    // Build the results list with a message when the minimum number of
    // characters hasn't been entered.
    _buildMinCharsList: function (q) {
      var self = this,
          $input = self.$input,
          $list = self.$list,
          options = self.options,
          $item = $('<li class="mp_min_chars" role="alert" />'),
          formatMinChars;

      // Don't display the minimum characters list when there are no
      // characters.
      if (!q.length) {
        self._hideAndEmptyList();

        return self;
      }

      self._emptyList();

      // Fire 'formatMinChars' callback.
      formatMinChars = options.formatMinChars && options.formatMinChars.call($input, options.minChars, $item);

      if (formatMinChars) {
        $item.html(formatMinChars);
      }

      self._trigger('minChars', [options.minChars, $item]);

      // Displaying a minimum characters message is optional. It isn't
      // displayed if the 'formatMinChars' callback returns a false value.
      if (formatMinChars) {
        $item.appendTo($list);

        self._showList();
      }
      else {
        self._hideList();
      }

      return self;
    },

    // Cancel any pending ajax request and input key buffer.
    _cancelPendingRequest: function () {
      var self = this;

      // Abort the ajax request if still in progress.
      if (self.ajax) {
        self.ajaxAborted = true;
        self.ajax.abort();
      }
      else {
        self.ajaxAborted = false;
      }

      // Clear the request buffer.
      clearTimeout(self.timer);

      return self;
    },

    // Make a request for the specified query and build the results list.
    _request: function (q) {
      var self = this,
          $input = self.$input,
          $list = self.$list,
          options = self.options;

      self._cancelPendingRequest();

      // Check if the input value has changed.
      self.change(q, true);

      // Requests are buffered the number of ms specified by the 'delay'
      // setting. This helps prevent an ajax request for every keystroke.
      self.timer = setTimeout(function () {
        var data = {},
            param = {},
            params = {},
            cacheKey,
            $inputParent = $();

        // Display the minimum characters message if not reached.
        if (q.length < options.minChars) {
          self._buildMinCharsList(q);

          return self;
        }

        // Get the additional data to send with the request.
        data = $.isFunction(options.data) ? options.data.call(self.$input, q) : options.data;

        // Add the query to be sent with the request.
        param[options.param] = q;

        // Merge all parameters together.
        params = $.extend({}, data, param);

        // Build the request URL with query string data to use as the cache
        // key.
        cacheKey = options.url + (options.url.indexOf('?') === -1 ? '?' : '&') + $.param(params);

        // Check for and use cached results if enabled.
        if (options.cache && cache[cacheKey]) {
          self._buildSuccessList(q, cache[cacheKey]);
        }
        // Otherwise, make an ajax request for the data.
        else {
          self._trigger('requestBefore');

          // Add a class to the input's parent that can be hooked-into by the
          // CSS to show a busy indicator.
          $inputParent = $input.parent().addClass('mp_busy');
          $list.attr('aria-busy', 'true');

          // The ajax request is stored in case it needs to be aborted.
          self.ajax = $.ajax({
            url: options.url,
            dataType: 'json',
            data: params,
            success:
              function (data) {
                self._buildSuccessList(q, data);

                // Cache the data.
                if (options.cache) {
                  cache[cacheKey] = data;
                }
              },
            error:
              function (jqXHR, textStatus, errorThrown) {
                // Show the error message unless the ajax request was aborted
                // by this plugin. 'ajaxAborted' is used because 'errorThrown'
                // does not faithfull return "aborted" as the cause.
                if (!self.ajaxAborted) {
                  self._buildErrorList(jqXHR, textStatus, errorThrown);
                }
              },
            complete:
              function (jqXHR, textStatus) {
                // Reset ajax reference now that it's complete.
                self.ajax = null;
                self.ajaxAborted = false;

                // Remove the "busy" indicator class on the input's parent.
                $inputParent.removeClass('mp_busy');
                $list.attr('aria-busy', 'false');

                self._trigger('requestAfter', [jqXHR, textStatus]);
              }
          });
        }
      }, options.delay);

      return self;
    },

    // Dismiss the results list and cancel any pending activity.
    _dismiss: function () {
      var self = this,
          options = self.options;

      self.focusPseudo = false;

      self
        ._cancelPendingRequest()
        ._hideAndEmptyList();

      // Empty the input value if the 'required' setting is enabled and nothing
      // is selected.
      if (options.required && !self.selectedData) {
        self.change('', true);
      }

      self
        ._toggleLabel()
        ._trigger('blur');

      return self;
    },

    // Trigger a callback subscribed to via an option or using .bind().
    _trigger: function (name, args) {
      var self = this,
          callbackName = 'on' + name.charAt(0).toUpperCase() + name.slice(1),
          triggerName = self.widgetEventPrefix.toLowerCase() + name.toLowerCase(),
          triggerArgs = $.isArray(args) ? args : [],
          callback = self.options[callbackName];

      self.element.trigger(triggerName, triggerArgs);

      return callback && callback.apply(self.element, triggerArgs);
    }
  });
}));
