/* might want to find a better home for this
 * from: http://simonwillison.net/2006/Jan/20/escape/
 */
RegExp.escape = function(text) {
  if (!arguments.callee.sRE) {
    var specials = [
      '/', '.', '*', '+', '?', '|',
      '(', ')', '[', ']', '{', '}', '\\'
    ];
    arguments.callee.sRE = new RegExp(
      '(\\' + specials.join('|\\') + ')', 'g'
    );
  }
  return text.replace(arguments.callee.sRE, '\\$1');
}

/**
 * @param id: (str) pop-up element id (ex: ul)
 * @param on_select: (fn) callback when selection is made
 */
var Popup = function(id, on_select){

  this.id = id;             // id of dom element that is root of pop-up
  this.selectedIndex = -1;  // current selection (0-based)
  this.listSize = 0;        // count of items in pop-up
  this.on_select = on_select;

  /** display selection pop-up */
  this.show = bind(function(){
    if(this.selectedIndex != -1){
      if((this.listSize - this.selectedIndex) < 2){
        this.selectedIndex = this.listSize-1;
      }
      this.setSelected(this.selectedIndex);
    }
    removeElementClass($(this.id),"hidden");
  }, this);

  /** hide selection pop-up */
  this.hide = bind(function(){
    addElementClass($(this.id),"hidden");
  }, this);

  /** return whether selection pop-up is visible */
  this.isVisible = bind(function(){
    return !hasElementClass($(this.id), "hidden");
  }, this);

  /** removes any selection */
  this.clearSelection = bind(function(){
    var nodes = getElementsByTagAndClassName("li", null, $(this.id));
    for(var n=0; n < nodes.length; n++)
      removeElementClass(nodes[n], "selected");
    this.selectedIndex = -1;
  },this);

  /** set selected index */
  this.setSelected = bind(function(index /* 0-based index */){
    this.clearSelection();
    var li = getElementsByTagAndClassName("li", null, $(this.id))[index]
    addElementClass(li,"selected");
    this.selectedIndex = index;
  }, this);

  /** select next, if not on last */
  this.selectNext = bind(function(){
    if((this.listSize - this.selectedIndex) < 2){
      return;
    }
    this.setSelected(this.selectedIndex+1);
  }, this);

  /** select previous, if not at first */
  this.selectPrev = bind(function(){
    if(this.selectedIndex == 0)
      return;
    this.setSelected(this.selectedIndex-1);
  },this);

  /** whether there's a valid selection -- don't trust selectedIndex */
  this.hasSelection = bind(function(){
    return this.selectedIndex >= 0 && this.selectedIndex < this.listSize;
  }, this);

  /** add list of choices to selection pop-up */
  this.addList = bind(function(fragment /* text in input */,
                               list /* completion options */,
                               format_item /* formatting callback - takes single option */){
    var html = map(function(v){return "<li>"+v+"</li>";}, 
                    map(function(v){return v.replace(new RegExp("("+escape(fragment)+")", "gi"), "<strong>$1</strong>");}, 
                        map(format_item,list)));
    $(this.id).innerHTML = html.join("\n");
    this.listSize = list.length;
  }, this);
  
  /** handle click on an item */
  this.onClick = bind(function(e){
      var target = e.target();
      // handle bold portions
      while(target != null && (target.tagName != "LI" || target.parentNode != e.src())){
        target = target.parentNode;
      }
      var items = getElementsByTagAndClassName("li", null, $(this.id));
      for(var i=0; i < items.length; i++){
        if(items[i] === target){
          this.setSelected(i);
          return this.on_select();  // don't care about return value
        }
      }
  },this);
  
  /** select item hovered over */
  this.onMouseOver = bind(function(e){
      var target = e.target();
      var n = target;
      while(n != null && (n.tagName != "LI" || n.parentNode != e.src())){
        n = n.parentNode;
      }
      var items = getElementsByTagAndClassName("li", null, $(this.id));
      for(var i=0; i < items.length; i++){
        if(items[i] === n){
          this.clearSelection();
          this.setSelected(i);
        }
      }
  },this);
  
  connect(id, "onclick", this, "onClick");
  connect(id, "onmouseover", this, "onMouseOver");
  
};

var Autocompleter = function(input /* form element node */,
                             get_list /* takes: fragment:str, max_results:int, set_list:fn */, 
                             options /* optional settings object */){

  // default values for options
  this.defaults = {
    delay: 500,   // milliseconds
    minChars: 3,
    maxChoices: 20,
    listId: input.name + "!ac_list",
    format_item: function(item){return item;},
    format_result: function(item){return item;}
  }
  
  this.input = input;
  this.get_list = get_list;
  // set option values
  options = options || {};
  this.delay = options.delay || this.defaults.delay;
  this.listId = options.listId || this.defaults.listId;
  this.maxChoices = options.maxChoices || this.defaults.maxChoices;
  this.minChars = options.minChars || this.defaults.minChars;
  // callbacks
  this.format_item = options.format_item || this.defaults.format_item;
  this.format_result = options.format_result || this.defaults.format_result;
  this.on_keydown = options.on_keydown;
  this.on_lookup_start = options.on_lookup_start;
  this.on_lookup_finish = options.on_lookup_finish;
  this.on_select = options.on_select;
  // 'bind' any given callbacks
  if(this.on_keydown)
    this.on_keydown = bind(this.on_keydown, this);
  if(this.on_lookup_start)
    this.on_lookup_start = bind(this.on_lookup_start, this);
  if(this.on_lookup_finish)
    this.on_lookup_finish = bind(this.on_lookup_finish, this);
  if(this.on_select)
    this.on_select = bind(this.on_select, this);
  // instance vars
  this.list = [];       // completion options
  var lastValue = null; // used to change if input.value has changed
  var timeout = null;   // 'opaque token' (from setTimeout, for clearTimeout)
  var hasFocus = 0;     // whether input has focus
  
  /**
   * Hide pop-up when input loses focus.
   */
  var onBlur = bind(function(e){
    hasFocus = 0;
    setTimeout(this.popup.hide, 200);
  },this);

  /**
   * Update completion options.
   */
  this.onChange = bind(function(e){
    var v = this.input.value;
    if(v == this.lastValue)
      return;
    this.lastValue = v;
    if(v.length >= this.minChars){
      if(!hasFocus)
        return;
      if(this.on_lookup_start)
        this.on_lookup_start();
      this.get_list(v, this.maxChoices, setList);
    }
    else
      this.popup.hide();
  },this);
  
  /**
   * Set possible completion options.
   */
  var setList = bind(function(list){
    var fragment = this.input.value;
    if(this.on_lookup_finish)
      this.on_lookup_finish();
    this.list = list = list || [];
    if(!hasFocus)
      return;
    this.popup.addList(fragment, list, this.format_item);
    if(list.length > 0)
      this.popup.show();
    else
      this.popup.hide();  // ??? what about 'no matches'?
  }, this);

  /**
   * Track that we've got focus.
   */
  var onFocus = function(e){
    hasFocus++;
  };

  /**
   * All key handling (including delegated).
   */
  var onKeyDown = function(e){
    // if callback specified, allow delegate to
    // handle keydown -- if callback returns
    // true, it handled it.
    if(this.on_keydown && this.on_keydown(e))
      return;
    var key = e.key();
    switch(key.code){
    case 38:    // up
      if(this.popup.isVisible())
        this.popup.selectPrev();
      break;
    case 40:    // down
      if(!this.popup.isVisible()){
        if(this.list.length > 0)
          this.popup.show();
      }
      else
        this.popup.selectNext();
      break;
    case 9:     // tab
    case 13:    // enter
      if(this.popup.isVisible() && this.popup.hasSelection()){
        this.onSelect();
        this.popup.hide();
        e.stop();
      }
      break;
    case 27:    // esc
      if(this.popup.isVisible()){
        this.popup.hide();
        e.stop();
      }
      break;
    default:
      if(timeout)
        clearTimeout(timeout);
      timeout = setTimeout(this.onChange, this.delay);
    }
  };

  /**
   * Called when a completion option is selected.
   */
  this.onSelect = bind(function(){
      var selection = this.list[this.popup.selectedIndex];
      this.lastValue = this.input.value = this.format_result(selection);
      if(this.on_select)
        this.on_select(selection);
  },this);
  
  this.popup = new Popup(this.listId, this.onSelect);
  
  connect(input, "onblur", this, onBlur);
  connect(input, "onfocus", this, onFocus);
  connect(input, "onkeydown", this, onKeyDown);
}
