/*
 *  jQuery Autocomplete plugin
 *  
 *  Version 1.0
 *  URL: http://borgar.net/svn/public/trunk/javascript/jquery.autocomplete/
 *
 *  Licenced under dual MIT/GPL license.
 *    GPL: http://www.opensource.org/licenses/gpl-2.0.php
 *    MIT: http://www.opensource.org/licenses/mit-license.php
 *
 *  This is a total rebuild of the Autocomplete plugins by Dylan Verheul, Dan G. Switzer, and Jörn Zaffaere.
 *  I've restructured and cleaned up the code, removed unnecessary features, added smarter caching and cleaner UI behavior.
 *  
*/
/*global jQuery, escape */
/*jslint browser: true, laxbreak: true, forin: true, newcap: true, immed: true */
(function($){

  var regExpCache = {};
  function rexcape ( s ) { return s.replace( /([.*+?\^${}()|\[\]\/\\])/g, '\\$1' ); }

  var defaultConfig = {
    data: null,             // initial data to use
    // data may be an async callback which will be called with the arguments [query, callback, extraData]
    // return the data as an array [['item',optinal_extras,..],['item',optinal_extras,..]] as the first argument
    // to the callback:  callback( my_data_array )
    // data will be cached if caching is true (default)

    inputClass:   'autocomplete-input',
    resultsClass: 'autocomplete-results',
    loadingClass: 'autocomplete-loading',
    activeClass:  'autocomplete-active',

    resultTemplate: '<ul></ul>',      // selection box template
    resultRowTemplate: '<li>%s</li>', // option item template

    noMatchesText: 'No matches',

    lineSeparator: '\n',    // when parsing textual data into system, data is split into rows by this character 
    cellSeparator: '|',     // when parsing textual data into system, each row is split by this character

    minChars: 2,            // minimum characters needed before the autocompleter starts doing anything
    delay: 100,             // the delay before an ajax request is sent

    matchCase: false,       // be case sensitive
    matchAnywhere: false,   // complete works by matching anywhere in the string, rather than just the start
    
    caching: true,          // don't know why you would want to turn this off? :-)

    extraParams: null,      // callback or hash of parmeters

    selectFirst: false,     // if enter is pressed and we have items, select the first one by default
    selectOnly: false,      // if enter is pressed in the box and only one item is available, then that is automatically selected
    select: null,           // called with the selection to allow a custom autocomplete, return false to kill default behavior. (this: widget, args: item-elm, value, extra)

    wrapNavigation: false,  // if user pushes past the end of item list the other end is selected (note: this kills up-to-close behaviour)
    width: 0,
    pageSize: 8,            // defines how many items are pages up/down with PgUp/PgDn buttons
    maxItemsToShow: 0,      // cut the number of items off at this point (if this is > 0)
    useIsoCharset: false,   // legacy autocomplete urls may serve iso-8859-1 to utf-8 pages

    parentIsScope: false,   // force plugin to regard parent element to the select input as the control context (impacts ARIA)

    resultsCallback: null,  // optional callback hook to mess with the results before display (this: container, args: data, max_num, q, widget )

    formatItem: function ( txt, q, i, l ) {
      var rx = regExpCache[q] || (regExpCache[q] = new RegExp( '(' + rexcape( q ).replace(/\s+/, '\\s+') + ')', 'i' ));
      return txt.replace( rx, '<strong>$1</strong>' );
    }

  };
  
  var KEY = {
    ALT: 17,
    BACKSPACE: 8,
    COMMA: 188,
    CONTROL: 224,
    DEL: 46,
    DOWN: 40,
    END: 35,
    ESC: 27,
    HOME: 36,
    META: 18,
    PAGEDOWN: 34,
    PAGEUP: 33,
    RETURN: 13,
    SHIFT: 16,
    TAB: 9,
    UP: 38
  };

  function Cache () {
    this.data = {};
  }
  $.extend(Cache.prototype,{
    
    flush: function () {
      this.data = {};
    },
    
    populate: function ( arr ) {
      // if there is a data array supplied
      if ( $.isArray(arr) ) {
        // build a structure like this: { a:[['about'],['after']], b:['boat','both'] }
        var sets = {};
        for ( var i = 0; i < arr.length; i++ ) {
          if ( arr[i].length ) {
            var row = $.isArray( arr[i] ) ? arr[i] : [ arr[i] ];
            var ch = row[0].charAt(0).toLowerCase();
            sets[ ch ] = sets[ ch ] || [];
            sets[ ch ].push( row );
          }
        }
        // merge the new structure to cache
        for ( var key in sets ) {
          this.add( key, sets[ key ] );
        }
      }
    },

    add: function ( q, data ) {
      if ( q && data ) {
        this.data[ q ] = this.data[ q ] ? this.data[ q ].concat( data ) : data;
      }
      return data;
    },
    
    get: function ( q ) {
      var out = [];
      if ( q === '*' ) {
        for ( var ch in this.data ) {
          out = out.concat( this.data[ch] );
        }
      }
      else if ( !q ) {
        out = [];
      }
      else {
        out = this.data[ q ] || null;
      }
      return out;
    },
    
    reduce: function ( q, matchCase, matchAnywhere ) {
      // reverse lookup until we find actual data
      var data, j = q;
      while ( j && !data ) {
        j = j.substring( 0, j.length - 1 );
        data = this.get( matchAnywhere ? '*' : j );
      }
      if ( data ) {
        return $.map(data, function (a) {
          var idx = a[ 0 ][ matchCase ? 'toString' : 'toLowerCase' ]().indexOf( q );
          return ( ( !matchAnywhere && idx === 0 ) || ( matchAnywhere && idx !== -1 ) ) ? [ a ] : null;
        });
      }
      return null;
    },
    
    has_reduced: function ( q ) {
      var l = [], d;
      while ( q ) {
        d = this.get( q );
        if ( d ) {
          l.push( d.length );
        }
        q = q.substring( 0, q.length - 1 );
      }
      return l.length > 1 && l[0] < l[l.length-1];
    }

  });


  function Autocompleter ( input, config ) {
    var self = this,
        elm = this.input = $( input ),
        cfg = this.config = $.extend( {}, defaultConfig, config );
        
    elm.attr( 'autocomplete', 'off' );

    // add ARIA to control scope (also done then results are built)
    if ( cfg.parentIsScope || /(^|\s)(form|fi_|ctrl)/i.test( elm.parent().get(0).className ) ) {
      elm.parent().attr( 'aria-relevant', 'text' );
    }

    if ( cfg.inputClass ) {
      elm.addClass( cfg.inputClass );
    }
    $.extend(this, {
      timeout:  null,
      prev:     '',
      active:   -1,
      cache:    new Cache(),
      keyb:     false,
      hasFocus: false,
      lastKey:  null,
      lastVal:  null
    });
    elm.data( 'autocomplete', this );

    // start with a clean cache
    this.cache.flush();

    // is data source a callback
    if ( typeof cfg.data === "function" ) {
      this.get_data = cfg.data;
    }
    // read data from config into the cache
    else if ( cfg.data ) {
      this.cache.populate( cfg.data );
    }

    // hook events
    elm.bind('keydown.autocomplete', function (e) { self.keyHandler( e ); });  // $(this).data('autocomplete')...
    elm.bind('focus blur click', function (e) {
        self.hasFocus = (e.type !== 'blur');
        if ( e.type == 'blur' && !self.mouseDownOnSelect ) {
          $( this ).data( 'autocomplete' ).hideResults();
          $( this ).trigger( 'incomplete' );
        }
        self.mouseDownOnSelect = false;
      });
    elm.bind('flushCache', function() { $( this ).data( 'autocomplete' ).cache.flush(); });
  }
  
  $.extend(Autocompleter.prototype, {

    keyHandler: function ( e ) {
      this.hasFocus = true; 
      // track last key pressed
      this.lastKey = e.keyCode;
      switch ( e.keyCode ) {
        case KEY.UP:
          this.moveSelection( -1 );
          e.preventDefault();
          break;
        case KEY.DOWN:
          // exception: if active == -1... then show autocomplete selection (if we have it?)
          if ( this.active === -1 && this.items && this.items.length ) {
            this.showResults();
          }
          this.moveSelection( 1 );
          e.preventDefault();
          break;
        case KEY.PAGEUP:
          this.moveSelection( -this.config.pageSize || -8 );
          e.preventDefault();
          break;
        case KEY.PAGEDOWN:
          this.moveSelection( this.config.pageSize || 8 );
          e.preventDefault();
          break;
        case KEY.HOME:
          this.moveSelection( -Infinity );
          e.preventDefault();
          break;
        case KEY.END:
          this.moveSelection( Infinity );
          e.preventDefault();
          break;
        case this.config.multiple && KEY.COMMA:
        case KEY.TAB:
          // FIXME: take a look at shift+Tab behavior
        case KEY.RETURN:
          if ( this.selectCurrent() ) {
            e.preventDefault();
            return false;
          }
          break;
        case KEY.ESC:
          this.revertSelection();
          break;
        default:
          var self = this;
          clearTimeout( this.timeout );
          // this.timeout = setTimeout(function(){ self.onChange(); }, this.config.delay || 15 );
          this.timeout = setTimeout(function(){ self.onChange(); }, 15 );
          break;
      }

      if ( this.input.val() == '' ) {
        this.active = -1;
        this.items = null;
        this.hideResults();
      }

    },


    onChange: function () {
      var val = this.input.val();
      if ( this.lastVal != val ) {
        if ( val && val.length >= this.config.minChars ) {
          this.input.addClass( this.config.loadingClass );
          this.requestData( val );
        }
        else {
          this.input.removeClass( this.config.loadingClass );
          this.hideResults();
        }
      }
      if ( !val ) {
        this.hideResults();
      }
      this.lastVal = val;
    },


    requestData: function ( q ) {

      var C = this.config
        , self = this
        , extraData = $.extend( {}, C.extraParams )
        ;
      if ( !C.matchCase ) {
        q = q.toLowerCase();
      }

      // recieve the cached data
      var data = C.caching ? this.cache.get( q ) : null;
      if ( data && data.length ) {
        this.receiveData( q, data );
      }
      else {
        // we have a contact point on the other side...
        if ( C.url ) {
          
          // results have shrunk already ... we need not look further
          if ( this.cache.has_reduced( q ) ) {
            this.receiveData( q, this.cache.reduce( q, C.matchCase, C.matchAnywhere ) );
          }
          // results are at a steady flow ... request more
          else {
            // TODO: support more formats (JSON)?
            // TODO: timeout delay here to que requests while typing
            if ( $.isFunction( extraData ) ) {
              extraData = extraData.call( this, q );
            }
            $.get(this.prepareURI( q ), extraData, function ( data ) {
              data = self.parseData( data );
              if ( self.config.caching ) {
                self.cache.add( q, data );
              }
              self.receiveData( q, data );
            });
          }
          
        }
        else if ( this.get_data ) {
          var cb = function ( data ) {
            if ( self.config.caching ) {
              self.cache.add( q, data );
            }
            self.receiveData( q, data );
          };
          this.get_data.call( null, q, cb, extraData );
        }
        // no ajax, we only have the cache
        else {
          this.receiveData( q, this.cache.reduce( q, C.matchCase, C.matchAnywhere ) );
        }
        
      }
      this.input.removeClass( C.loadingClass );

    },


    parseData: function (data) {
      if ( data ) {
        var C = this.config;
        return $.map(data.split( C.lineSeparator ), function ( row ) {
          row = $.trim( row );
          return row ? [ row.split( C.cellSeparator ) ] : null;
        });
      }
      return [];
    },
    
    
    receiveData: function ( q, data ) {
      // we still have focus, and what we requested is what is in the box
      if ( data && data.length && this.hasFocus 
            && (this.input.val().toLowerCase() == q.toLowerCase()) ) {
        this.buildResults( data, q );
        this.showResults();
        // autofill in the complete box w/the first match as long as the user hasn't entered in more data
        if ( this.config.autoFill ) {
          this.autoFill( data[0][0] );
        }
      }
      else {
        this.active = -1;
        this.hideResults();
      }
    },


    buildResults: function ( data, q ) {
      var container, C = this.config, needsEvent = true;

      if ( this.select ) {
        needsEvent = false;
        container = this.select;
        container.empty();
      }
      else {
        this.select = container = $( C.resultTemplate )
            .attr({
               'aria-relevant': 'text',
               'aria-live': 'polite'
            })
            .addClass( C.resultsClass )
            .data( 'autocomplete', this );
      }

      // row template
      var rowTpl = C.resultRowTemplate;
      if ( rowTpl.indexOf('%s') !== -1 ) {
        rowTpl.replace( '><', '>%s<' ); // move to init?
      }

      // formatting function
      var FMT = $.isFunction( C.formatItem ) ? C.formatItem : String;
      
      // limit results to a config max number
      var num = Math.min( ( C.maxItemsToShow > 0 ? C.maxItemsToShow : Infinity ), data.length );
      
      // set the initial selected item (textbox unless options define first item)
      this.active = ( C.selectFirst || C.selectOnly && num === 1 ) ? 0 : -1;
      
      // build options
      if ( num ) {
        for ( var row, f, i = 0; i < num; i++ ) {
          row = data[i];
          if ( row ) {
            f = FMT( row[0], q, i, num, row );
            if ( f ) {
              var item = $( rowTpl.replace( '%s', f ) );
              if ( i === 0 ) {
                this.childTagNames = item[0].tagName.toLowerCase();
              }
              item.data( 'value', row[0] );
              item.data( 'extra', row.slice(1) );
              item.appendTo( container );
              if ( i == this.active ) {
                item.addClass( C.activeClass );
              }
            }
          }
        }
        this.items = container.children();
      }
      else if ( C.noMatchesText ) {
        container.html( rowTpl.replace( '%s', C.noMatchesText ) );
      }

      // allow extrnal programs to mess with the results
      if ( typeof C.resultsCallback === 'function' ) {
        C.resultsCallback.call( container[0], data, num, q, this );
      }

      var self = this;

      if ( needsEvent ) {
        container
          .bind('click', function ( e ) {
            e.preventDefault();
            var ok = false;
            for (var i=0,l=self.items.length; i<l; i++) {
              if ( self.items[i] === e.target || $.contains( self.items[i], e.target ) ) {
                ok = true;
                break;
              }
            }
            // we are in an item (assumed to be the correct one), perform the selection
            if ( parent ) {
              self.selectCurrent();
            }
          })
          .bind('mousemove', function ( e ) {
            var index = -1;
            for (var i=0,l=self.items.length; i<l; i++) {
              if ( self.items[i] === e.target || $.contains( self.items[i], e.target ) ) {
                index = i;
                break;
              }
            }
            self.moveSelection( index, true, true  );
            clearTimeout( self.killTimer );
          })
          .bind('mouseout', function ( e ) {
            self.killTimer = setTimeout(function () {
              self.moveSelection( -1, true, true );
            }, 15);
          })
          .bind('mousedown', function(e){
            self.mouseDownOnSelect = true;
          });
      }

      return container;
    },


    clear: function () {
      this.lastVal = '';
      this.revertSelection();
      this.input.trigger( 'change' );
    },

    
    revertSelection: function ( hide ) {
      this.input.val( this.lastVal );
      this.active = -1;
      this.items = null;
      this.hideResults();
    },
    
    
    hideResults: function () {
      if ( this.select ) {
        this.select.hide();
      }
    },


    showResults: function () {
      // get the position of the input field right now (in case the DOM is shifted)
      if ( this.select && this.items.length ) {
        var pos = this.input.offset(), _sel = this.select;
        _sel.hide().appendTo( document.body );

        // either use the specified width, or calculate based on form element
        var wid = this.config.width ;
        if ( !wid ) {
          wid = this.input.outerWidth();
          wid -= parseInt( _sel.css('border-left-width') || 0, 10 );
          wid -= parseInt( _sel.css('border-right-width') || 0, 10 );
          wid -= parseInt( _sel.css('padding-left') || 0, 10 );
          wid -= parseInt( _sel.css('padding-right') || 0, 10 );
        }

        // reposition
        _sel.css({
            position: 'absolute',
            width: parseInt( wid, 10 ) + 'px',
            top: ( pos.top + this.input.outerHeight() ) + 'px',
            left: pos.left + 'px'
          }).show();
      }
      else {
        this.hideResults();
      }
    },


    moveSelection: function ( step, keepOpen, absolute ) {
      if ( this.items ) {
        var newpos = absolute ? step : this.active + step;
        var max_item = this.items.length - 1;
        var pos = -1;
        if ( !absolute && this.config.wrapNavigation ) {
          pos = newpos < 0 ? max_item : newpos > max_item ? 0 : newpos;
        }
        else {
          pos = newpos < -1 ? -1 : newpos > max_item ? max_item : newpos;
        }
        this.items.removeClass( this.config.activeClass );
        var selItem = pos > -1 ? this.items.eq( pos ) : $();
        selItem.addClass( this.config.activeClass );
        // TODO: scroll the list if it is scrollable and chosen setting is out of view
        // TODO: this is broken on "select anywhwere" ... can we support selections with a mask?
        // select the "new" parts of the string... 
        if ( !this.config.matchAnywhere ) {
          var newVal = selItem.data( 'value' ) || this.lastVal;
          this.input.val( this.lastVal + newVal.substring( this.lastVal.length ) );
          this.createSelection( this.lastVal.length, newVal.length );
        }
        if ( !keepOpen && pos === -1 && this.active !== -1 ) {
          this.revertSelection();
        }
        this.active = pos;
      }
      else {
        this.active = -1;
      }
    },


    selectCurrent: function () {
      var I = this.items,
          C = this.config,
          item;
      if ( I && I.is(':visible') && this.active > -1 ) {
        item = I.eq( this.active );
        if ( !item.length && ( (C.selectOnly && I.length == 1) || (C.selectFirst && I.length) ) ) {
          item = I.eq( 0 );
        }
      }
      if ( item && item.length ) {
        var val = $.trim( item.data( 'value' ) );
        var xtr = item.data( 'extra' );
        var r = null;
        if ( typeof C.select === 'function' ) {
          r = C.select.call( this, item, val, xtr );
        }
        if ( r !== false ) {
          this.input.val( val );
          this.lastVal = val; // disallow reverting from selected items
          this.createSelection( val.length, val.length );
          this.hideResults();
          // nuke option items
          this.items.remove();
          this.items = null;
          this.input.trigger( 'autocomplete', [ val, xtr ] ); // change?
          // TODO: callback?
        }
        return true;
      }
      return false;
    },


    // selects a portion of the input string
    createSelection: function ( start, end ) {
      // get a reference to the input element
      var elm = this.input[0];
      if ( elm.createTextRange ){
        var selRange = elm.createTextRange();
        selRange.collapse( true );
        selRange.moveStart( 'character', start );
        selRange.moveEnd( 'character', end );
        selRange.select();
      } 
      else if ( elm.setSelectionRange ){
        elm.setSelectionRange( start, end );
      } 
      else if ( elm.selectionStart ) {
        elm.selectionStart = start;
        elm.selectionEnd = end;
      }
    },


    prepareURI: function ( q ) {
      var val = (this.config.useIsoCharset ? escape : encodeURI)( q );
      var url = this.config.url;
      // TODO: callback 
      url = ( url.indexOf('%s') !== -1 ) ? url.replace( '%s', val ) : url + val;
      return url;
    }

  });


  // expose plugin to jQuery:

  $.autocomplete = function ( input, config ) {
    return new Autocompleter( input, config );
  };
  $.autocomplete.config = defaultConfig;


  $.fn.extend({
    // possible parameter variations:
    //   param: 
    //   param: url
    //   param: config
    //   param: url, config
    //   param: url, config, data
    autocomplete: function ( ) {
      var url, config = {}, data,
          args = $.makeArray( arguments );
      // tolerate any order for parameters
      while ( args.length ) {
        var d = args.pop(), t = typeof d;
        if ( $.isArray( d ) ) { data = d; }
        else if ( t == 'string' ) { url = d; }
        else if ( t == 'object' ) { config = d; }
      }
      if ( url ) { config.url = url; }
      if ( data ) { config.data = data; }
      return this.each(function () {
        $.autocomplete( this, config );
      });
    }
  });

}(jQuery));

