// https://prototype.lighthouseapp.com/projects/8886/tickets/356-arrayinsert#ticket-356-3
Object.extend(Array.prototype, {
  insert: function(index) {
    var args = Array.prototype.slice.call(arguments, 1);
    this.length = Math.max(this.length, index);
    index = index < 0 ? this.length : index;

    if (args.length > 1)
      this.splice.apply(this, [index, 0].concat(args));
    else this.splice(index, 0, args[0]);
    return this;
  }
});

var CastTV = function(){
	var memoizedPlaybacks = {}; // id => playback
	var siblingGroups = []; // array of sibling groups (arrays)
  var siblingGroupIndex = {}; // id => sibling group (array)

	var playbackType = (function() {
    var type = document.location.pathname.replace(/^\/(([\-a-z0-9]+)\/.*)?/i, '$2');
    if ('' === type) { return 'home'; }
    return type;
  })();

  var isFullScreen = function() {
    return Math.abs(document.viewport.getWidth() - screen.availWidth) < 15;
  };

  /* CastTV methods */

  var inPopout = (function() { return 'popout' === playbackType; })();
  var inEmbed =  (function() { return 'embed'  === playbackType; })();

  var addedScriptaculousDivScroll = (function() {
    // http://www.garyharan.com/index.php/2007/11/26/how-to-unobtrusively-scroll-a-div-with-prototype-scriptaculous/
    Element.addMethods({
      scrollTo: function(id, left, top){
        var element = $(id);
        if (arguments.length === 1){
          var pos = element.cumulativeOffset();
          window.scrollTo(pos[0], pos[1]);
        } else {
          element.scrollLeft = left;
          element.scrollTop  = top;
        }
        return element;
      }
    });
    
    Effect.Scroll = Class.create();
    Object.extend(Object.extend(Effect.Scroll.prototype, Effect.Base.prototype), {
      initialize: function(element) {
        this.element = $(element);
        if (!this.element) {
          throw(Effect._elementDoesNotExistError);
        }
        this.start(Object.extend({x: 0, y: 0}, arguments[1] || {}));
      },
      setup: function() {
        var scrollOffsets = (this.element === window) ? document.viewport.getScrollOffsets() : Element._returnOffset(this.element.scrollLeft, this.element.scrollTop);
        this.originalScrollLeft = scrollOffsets.left;
        this.originalScrollTop  = scrollOffsets.top;
      },
      update: function(pos) {
        this.element.scrollTo(Math.round(this.options.x * pos + this.originalScrollLeft), Math.round(this.options.y * pos + this.originalScrollTop));
      }
    });
  })();

  var DateHelper = {
    // Takes the format of "Jan 15, 2007 15:45:00 GMT" and converts it to a relative time
    // Ruby strftime: %b %d, %Y %H:%M:%S GMT
    time_ago_in_words_with_parsing: function(from) {
      var date = new Date;
      date.setTime(Date.parse(from));
      return this.time_ago_in_words(date);
    },

    time_ago_in_words: function(from) {
      return this.distance_of_time_in_words(new Date, from);
    },

    distance_of_time_in_words: function(to, from) {
      var distance_in_seconds = ((to - from) / 1000);
      var distance_in_minutes = Math.round(distance_in_seconds / 60);

      if (distance_in_minutes <= 0) { return 'less than a minute'; }
      if (distance_in_minutes == 1) { return '1 minute'; }
      if (distance_in_minutes < 45) { return distance_in_minutes + ' minutes'; }
      if (distance_in_minutes < 90) { return 'about 1 hour'; }
      if (distance_in_minutes < 1440) { return 'about ' + Math.round(distance_in_minutes / 60) + ' hours'; }
      if (distance_in_minutes < 2880) { return '1 day'; }
      if (distance_in_minutes < 43200) { return Math.round(distance_in_minutes / 1440) + ' days'; }
      if (distance_in_minutes < 86400) { return 'about 1 month'; }
      if (distance_in_minutes < 525960) { return Math.round(distance_in_minutes / 43200) + ' months'; }
      if (distance_in_minutes < 1051199) { return 'about 1 year'; }

      return 'over ' + Math.round(distance_in_minutes / 525960) + ' years';
    }
  };

  var escapeSearchQuery = function(s) {
    return s.replace("%", "%25").replace("/", " ").replace("?", "%3F").replace("+", "%2B").replace(/"/g, "%22").replace("#", "%23");
  };

  var easeInOut = function(t, b, c, d) {
    if ((t/=d/2) < 1) return c/2*t*t + b;
    return -c/2 * ((--t)*(t-2) - 1) + b;
  };

  var scrollIntoView = function(element) {
    // desired position solves this equation:
    // scrollY + viewH = pos + eleH + padding

    var padding = 40;
    var scroll = document.viewport.getScrollOffsets().top;
    var viewH = document.viewport.getHeight();  
    var pos = element.cumulativeOffset().top;
    var h = element.getHeight();
    var finalScroll = pos + h + padding - viewH;
    var delta = finalScroll - scroll;
    var i = 0;
    var steps = 10;
    var dt = 0.05;

    // var absDiff = Math.abs(scroll - finalScroll);
    if(scroll < finalScroll) {
      var pe = null;
      pe = new PeriodicalExecuter(function(pe) {
        window.scrollTo(0, easeInOut(i++, scroll, delta, steps));
        if(i >= steps) { pe.stop(); }
      }, dt);
    } // else {
     //      alert('not moving. scroll is ' + scroll + ' and finalScroll is ' + finalScroll + '.  abs diff is ' + absDiff + ' and viewH is ' + viewH + ' and delta is: ' + delta);
     //    }
  };

	var validateEmail = function(email) {
	  // http://www.dustindiaz.com/update-your-email-regexp/#comment-13787
	  var emailRegex = /^(?:[a-zA-Z0-9_\.\-+])+@(?:(?:[a-zA-Z0-9\-])+\.)+(?:[a-zA-Z0-9]{2,4})+$/;
	  return email !== null && !!email.match(emailRegex);
	};
	
	// TODO when we refactor, this should be moved into sharepanel-specific code
	var validateEmails = function() {
	  var valid = true;
	  $A(arguments).each(function(arg) {
      if (!validateEmail(arg)) {
        valid = false;
        return $break;
      }
    });
    
    return valid;
	};

  // NOTE: apparently this doesnt work in its current form for Safari.
	var addBookmark = function(url, title) {
		if (window.sidebar) { // firefox
			window.sidebar.addPanel(title, url, "");
		} else if (window.opera && window.print) { // opera
			var elem = document.createElement('a');
			elem.setAttribute('href',url);
			elem.setAttribute('title',title);
			elem.setAttribute('rel','sidebar');
			elem.click();
		} else if (document.all) { // ie
      window.external.AddFavorite(url, title);
    }
	};

  // where context is something like "searchbox", type is something like "tv show", name is something like "scrubs", and url is something like "/shows/scrubs"
  var trackUtilizedSuggestion = function(context, type, name, url) {
    (new Image()).src = "/t.gif?context="+encodeURIComponent(context)+"&type="+encodeURIComponent(type)+"&name="+encodeURIComponent(name)+"&url="+encodeURIComponent(url)+"&r="+Math.random();
  };

	var installBrowserSearchEngine = function() {
    if (window.external && ("AddSearchProvider" in window.external)) {
      // Firefox 2 and IE 7, OpenSearch
      window.external.AddSearchProvider("http://www.casttv.com/opensearch.xml");
    } else {
      // No search engine support (IE 6, Opera, etc).
      // edwin also pulled out Firefox <= 1.5, Sherlock
      alert("This feature is only supported on Firefox 2+ and IE 7+ browsers only.");
    }
  };

  var get = function(url, fn) {
    return new Ajax.Request(url, { method: 'get', evalScripts: true, onComplete: fn } );
  };

  var update = function(id, url, fn, errFn) {
    return new Ajax.Updater(id, url, { method: 'get', evalScripts: true, onComplete: fn } );
  };

  var isSafeSearchOff = function() {
    return 'off' === Cookie.get('safe');
  };

  var searchUrl = function(query, src) {
    var url = '';
    if (query == null || query === '') {
      url = '/';
    } else {
      url = '/search/' + escapeSearchQuery(query) + '/1' + (isSafeSearchOff() ? '?safe=off' : '');
    }

    if (src) {
      url += (url.match(/\?/) ? '&' : '?') + 'src=' + src;
    }
    return url;
  };

  var openSearchPage = function(query, src) {
    window.open(searchUrl(query, src));
  };

  var fromUs = function(usFn, nonUsFn) {
    get('/location/from_us', function(transport) {
      if (transport.responseText.match(/<Result>([^<]*)/)){
        var answer = RegExp.$1;
        if ('true' === answer && usFn) {
          usFn();
        } else if ('false' === answer && nonUsFn) {
          nonUsFn();
        }
      }
    });
  };

  // +routes+ is a an array of hashes: string/regex key mapping to function value
  //   the first key to match will have its function called with k1=v1&k2=v2 params parsed
  //   parsed into a hash as the first and only parameter
  var processAnchorRoute = function(routes) {
    var fullRoute = document.location.hash.slice(1);
    var match = fullRoute.match(/^([^\?]*?)(?:\?(.*))?$/);
    var baseRoute = match[1];
    var paramString = match[2];
    var params = paramString ? paramString.toQueryParams() : {};

    var getFirstQsKey = function(qsObj) {
      for (var key in qsObj) {
        if (qsObj.hasOwnProperty(key)) {
          return key;
        }
      }
    };

    var matchedRoute = routes.find(function(elt) {
      var pattern = getFirstQsKey(elt);

      if ('string' === typeof pattern) {
        return baseRoute === pattern;
      } else { // regex
        return baseRoute.match(pattern);
      }
    });

    if (matchedRoute) {
      var pattern = getFirstQsKey(matchedRoute);
      matchedRoute[pattern](params);
    }
  };

  // this and the toolbar resize need to be refactored.  should be able to say currentPlayback.resize or something
  var resizePlayback = function() {
    if (this.smallSharePanelVisibility == null) {
      this.smallSharePanelVisibility = true;
    }
    var shareNode = $$('#playback_container .share')[0];
    if (shareNode) {
      var sharePanelNode = shareNode.select('.share_panel')[0];
      var standardForm = shareNode.select('.standard_form')[0];
      var postVideoPanel = shareNode.select('.post_video_panel')[0];
      var shareToggles = shareNode.select('.tiny_more_less_container')[0];
    
      if (document.viewport.getWidth() < 420) {
        if (shareNode.getWidth() > 305) { // 305 instead of 300 because sometimes browsers make it a little bigger than you asked for
          if (!this.smallSharePanelVisibility) {
            sharePanelNode.hide();
          } else {
            sharePanelNode.show();
          }
        }

        standardForm.addClassName('standard_form_small');
        postVideoPanel.addClassName('post_video_panel_small');

        shareNode.setStyle({ width: '300px' });
        shareToggles.show();
      } else {
        if (shareNode.getWidth() < 399) {
          this.smallSharePanelVisibility = sharePanelNode.visible();
          sharePanelNode.show(); 
        }

        standardForm.removeClassName('standard_form_small');
        postVideoPanel.removeClassName('post_video_panel_small');

        shareNode.setStyle({ width: '399px' });
        shareToggles.hide();
      }
    }

    if ($$('#playback_container .toolbar')[0]) {
      $$('#playback_container .playback')[0].style.height = '' + (document.viewport.getHeight() - 24) + 'px';
    }
  };

  var resizeToolbar = function() {
    var pbNode =  $$('#playback_container .playback')[0];
    var pb = CastTV.playbacks(pbNode.id.match(/playback_(\w+)/)[1]);
    var tbNode = pbNode.select('.toolbar')[0];
    var toolbarWidth = (tbNode || document.viewport).getWidth();
    
    var classesToRemove = function(exception) {
      return $A(['between_300_365', 'between_240_300', 'between_160_240', 'below_160']).without(exception);
    };
    var klass = null;
    if (toolbarWidth < 365 && toolbarWidth >= 300) {
      klass = "between_300_365";
    } else if (toolbarWidth < 300 && toolbarWidth >= 240) {
      klass = "between_240_300";
    } else if (toolbarWidth < 240 && toolbarWidth >= 160) {
      klass = "between_160_240";
    } else if (toolbarWidth < 160) {
      klass = "below_160";
    }
    
    if (klass) { pbNode.addClassName(klass); }
    classesToRemove(klass).each(function(unnecessaryClass) {
      pbNode.removeClassName(unnecessaryClass);
    });
    
    var expandIcon = pb.toolbar.icon('expand');
    var fs = isFullScreen();
    var toggled = expandIcon.toggled();
    if ((fs && !toggled) || (!fs && toggled)) {
      expandIcon.toggle();
    }
    
    if (tbNode) {
      var searchBoxWidth = pb.toolbar.searchBoxWidth();
      pbNode.select('.player_search_box_left')[0].setStyle({ 'width': (''+ searchBoxWidth +'px') });
    }
      
    var raceConditionTimer = setTimeout(function() {
      // in case it look longer for us to change icon classes than to calculate and resize the toolbar
      if (searchBoxWidth != pb.toolbar.searchBoxWidth()) {
        CastTV.resizeEmbed();
      }
    }, 150);
  };

  var resizeEmbed = function() {
    resizePlayback();
    resizeToolbar();
  };

  // TODO: this should be a static function of playback(s)
  var registerSiblingGroup = function(group) {
    var alreadyExists = false;
    siblingGroups.each(function(g) {
      if (group === g) {
        alreadyExists = true;
        group = g;
        return $break;
      }
    });

    if (! alreadyExists) {
      siblingGroups.push(group);
      group.each(function(id) {
        siblingGroupIndex[id] = group;
      });
    }
    return group;
  };
  
  // TODO: this should be a static function of playback(s)
  var getSiblingGroup = function(id) {	  
    return siblingGroupIndex[id];
  };

  // access to an arbitrary thumbnail
	var thumbnails = function(hash) {
	  var thumbnail = $('video_block_' + hash);
	  var selectedClass = 'video_block_selected';
	  var hoverClass = 'video_block_hover';
	  
	  var selected = function() {
	    return thumbnail && thumbnail.hasClassName(selectedClass);
	  };
	  
	  var select = function() {
		  if (thumbnail && !selected()) {
			  thumbnail.addClassName(selectedClass);
			  thumbnail.removeClassName(hoverClass);
		  }
	  };
	  
	  var deselect = function() {
	    if (thumbnail && selected()) {
		    thumbnail.removeClassName(hoverClass);
	      thumbnail.removeClassName(selectedClass);
      }
	  };
	  
	  return {
		  selected: selected,
	    select: select,
		  deselect: deselect
	  };
	};
	
	// access to an arbitrary container
	var containers = function(id) {
    var node = !id ? $('playback_container') : $('playback_container_' + id);
    if (node && null === node.inTransition) {
      node.inTransition = false;
    }

    var currentPlayback = function() {
      var pb = null;
      var pbNode = node.getElementsBySelector('.outer_playback')[0];
      if (pbNode) {
        var playbackId = pbNode.id.match(/outer_playback_(\w+)/)[1];
        pb = CastTV.playbacks(playbackId);
      }
      return pb;
    };
    
    var transitionTo = function(toHash, callback) {
      if (node.inTransition) {
        return;
      }
      node.inTransition = true;
      
      var highlightedSectionRowClass = 'section_row_highlighted';
      
      var from = currentPlayback();
      if (from) {
        var fromHash = from.hash;
        CastTV.thumbnails(fromHash).deselect();

        var fromSectionRow = $('section_row_' + fromHash) || $$('.also_video_' + fromHash)[0];
        var toSectionRow = $('section_row_' + toHash) || $$('.also_video_' + toHash)[0];

        if (fromSectionRow) {
          fromSectionRow.removeClassName(highlightedSectionRowClass);
        }

        var replacementComplete = function() {
          CastTV.thumbnails(toHash).select();
          if (toSectionRow) {
            toSectionRow.addClassName(highlightedSectionRowClass);
          }
          node.inTransition = false;
          if (callback) {
            callback();
          }
        };
        
        if (Prototype.Browser.IE) {
          from.stashContent();
        }
        
        var to = memoizedPlaybacks[toHash];
        if (to) {
          // reload content from the stash, if it's stashed.
          to.reloadContent();
          node.replaceChild(to.node, from.node);
          replacementComplete();
        } else {
          var url = '/'+playbackType+'/playback/'+toHash;
          if (Prototype.Browser.IE) {
            // only if we're in IE do we have to temporarily replace the existing
            //  playback with a dummy node, so that Prototype's ajax update doesn't
            //  cause IE to clobber it
            node.replaceChild(document.createTextNode(''), from.node);
          }
          CastTV.update(node.id, url, replacementComplete);
        }
      }
    };
    
    return {        
      transitionTo: transitionTo
    };
  };
	
	// access to an arbitrary (memoized) playback
	var playbacks = function(hash) {
    var existing = memoizedPlaybacks[hash];
    var toolbarHeight = 24; // this is also set in our presenter
    
    if (! existing) {
      
      var createPlayback = function() {
        var node = $('outer_playback_' + hash);
        var contentNode = node.select('.flashcontent')[0] || node.select('.objectcontent')[0];
        var searchTextbox = node.select('.player_search_text')[0];
        var container = function() {
          var match = node.parentNode.id.match(/playback_container_(\w+)/);
          return CastTV.containers(match ? match[1] : null);
        }();
        var popoutUrl = (function() { return '/popout/' + hash; })();
        var prevHash = null;
        var nextHash = null;
        var width = null;
        var height = null;
        var toggleable = false;
        var stashedContent = null;
        // for remembering window popout location before going fullscreen.
        var originalX = null;
        var originalY = null;
        var postToggleCallback = null;

        // removes content html from the dom
        var stashContent = function() {
          if (null == stashedContent && contentNode) {
            stashedContent = contentNode.innerHTML;
            contentNode.update();
          }
        };
        // loads content html back from the stash
        var reloadContent = function() {
          if (stashedContent && contentNode) {
            contentNode.innerHTML = stashedContent;
          }
        };
        
        var openPopout = function(w, h) {
          var opts = "status=no,toolbar=no,location=no,menubar=no,directories=no,resizable=1,scrollbars=no";
          // TODO following stmt needs to be cleaned up
          if (w && h) {
            opts = "width="+w+",height="+h+","+opts;
            if (w == screen.availWidth) {
              opts = "left=0,top=0,"+opts;
            }
          } else {
            opts += ",type=fullWindow,fullscreen";
          }
          var popup = open(popoutUrl, hash, opts);
          // give 1.4 sec for the popup to to show, then assume that the js function will then eventually load because js is enabled
          var attempts = 7;
          var registerDelay = new PeriodicalExecuter(function(pe) {
            if ('undefined' === typeof(popup) || !popup) {
              if (0 == --attempts) {
                pe.stop();
                alert('Oops! Your browser blocked our pop-up window.  Please check your settings to allow pop-ups from casttv.com.');
              }
              return;
            }
            if (popup.registerSiblingGroup) {
              pe.stop();
              if (w == screen.availWidth) {
                // the following lines are necessary if user pops out and then in the original window presses full screen
                popup.moveTo(0,0);
                popup.resizeTo(screen.availWidth, screen.availHeight);
              }
              var thisPlayback = CastTV.playbacks(hash);
              if (thisPlayback && thisPlayback.isToggleable()) {
                thisPlayback.hide();
                if (postToggleCallback) {
                  postToggleCallback(false);
                }
              } else {
                // force the video to reload so that it stops playing.  this currently gives undesired behavior for auto-playing videos
                // only do this if there is no embed bumper present
                if (!$$('.embed_bumper')[0] || !$$('.embed_bumper')[0].visible()) {
                  stashContent();
                  reloadContent();
                }
              }
              popup.registerSiblingGroup(CastTV.getSiblingGroup(hash) || []);
            }
          }, 0.2);
          
          if (embedTrack) {
           embedTrack(hash, 'popout');
          }
        };

        // TODO we're going to want to refactor these buttons so that we can do playback.popout(), etc., while keeping toolbar button registration elegant
        var buttons = {
          previous: {
            action: function() {
              if (prevHash) {
                var videoGroup = CastTV.VideoGroup.findByBlockId(prevHash);
                if (videoGroup) {
                  videoGroup.setActiveBlock(prevHash);
                } else {
                  container.transitionTo(prevHash, function() {
                    if (window.onresize) {
                      resizeEmbed();
                    }
                  });
                }
              }
            }
          },
          
          next: {
            action: function() {
              if (nextHash) {
                var videoGroup = CastTV.VideoGroup.findByBlockId(nextHash);
                if (videoGroup) {
                  videoGroup.setActiveBlock(nextHash);
                } else {
                  container.transitionTo(nextHash, function() {
                    if (window.onresize) {
                      resizeEmbed();
                    }
                  });
                }
              }
            }
          },
          
          attribution: {
          },
          
          search: {
            registered: function() {
              if (searchTextbox) {
                searchBoxController = new CastTV.SearchBox(node, { trackingCode: 'emb' });
              }
            }
          },
          
          info: {
            action: function() {
              var panel = panels('info');
              if (! panel.visible()) {
                panel.show();
              } else {
                panel.hide();
              }
            }
          },
          
          permalink: {
            action: function() {
              var panel = panels('permalink');
              if (! panel.visible()) {
                panel.show();
              } else {
                panel.hide();
              }
            }
          },
          
          share: {
            registered: function() {
              var panel = panels('share');
              if (null === panel) { return; }
              var fromTextbox = panel.node.select('.from')[0];
              fromTextbox.placeholderText = 'Your Email Address';
              var fromTextboxBlur = function() {
                if ('' === fromTextbox.value) {
                  fromTextbox.addClassName('form_input_placeholder_text');
                  fromTextbox.value = fromTextbox.placeholderText;
                }
              };
              fromTextbox.observe('focus', function() {
                if (fromTextbox.placeholderText === fromTextbox.value) {
                  fromTextbox.value = '';
                  fromTextbox.removeClassName('form_input_placeholder_text');
                }
              }).observe('blur', fromTextboxBlur);
              var toTextbox = panel.node.select('.to')[0];
              toTextbox.placeholderText = 'Your Friend\'s Email Address';
              var toTextboxBlur = function() {
                if ('' === toTextbox.value) {
                  toTextbox.addClassName('form_input_placeholder_text');
                  toTextbox.value = toTextbox.placeholderText;
                }
              };
              toTextbox.observe('focus', function() {
                if (toTextbox.placeholderText === toTextbox.value) {
                  toTextbox.value = '';
                  toTextbox.removeClassName('form_input_placeholder_text');
                }
              }).observe('blur', toTextboxBlur);
              var messageTextbox = panel.node.select('.message')[0];
              messageTextbox.placeholderText = 'Optional Message';
              var messageTextboxBlur = function() {
                if ('' === messageTextbox.value) {
                  messageTextbox.addClassName('form_input_placeholder_text');
                  messageTextbox.value = messageTextbox.placeholderText;
                }
              };
              messageTextbox.observe('focus', function() {
                if (messageTextbox.placeholderText === messageTextbox.value) {
                  messageTextbox.value = '';
                  messageTextbox.removeClassName('form_input_placeholder_text');
                }
              }).observe('blur', messageTextboxBlur);
              fromTextboxBlur();
              toTextboxBlur();
              messageTextboxBlur();
            },
            // TODO panel should be a instance var of this obj, set in a constructor
            // TODO to and fromField should be also, and their div ids should be namespaced with the playback hash
            action: function() {
              var panel = panels('share');
              if (! panel.visible()) {
                panel.show();
              } else {
                panel.hide();
              }
            },
            resetPanel: function() {
              var panel = panels('share');
              panel.updateInfo('Fill out the form below.');
              var toTextbox = panel.node.select('.to')[0];
              if (!toTextbox.hasClassName('form_input_placeholder_text')) {
                if (toTextbox.placeholderText) {
                  toTextbox.value = toTextbox.placeholderText;
                }
                toTextbox.addClassName('form_input_placeholder_text');
              }
              var messageTextbox = panel.node.select('.message')[0];
              if (!messageTextbox.hasClassName('form_input_placeholder_text')) {
                if (messageTextbox.placeholderText) {
                  messageTextbox.value = messageTextbox.placeholderText;
                }
                messageTextbox.addClassName('form_input_placeholder_text');
              }
            }
          },
          
          embedcode: {
            action: function() {
              var panel = panels('embedcode');
              if (! panel.visible()) {
                panel.show();
              } else {
                panel.hide();
              }
            }
          },
          
          add_to_favorites: {
            action: function() {
              var iconNode = toolbar.icon('add_to_favorites').node;
              var dialog = new FavoritePickerDialog({'title': 'Add to Favorites', 'owner': node.select('.playback_embed_inner')[0] || node.select('.playback_failed_embed')[0], 'withinOwner': true, 'limit': 1, 'hash': hash, 'topRightClose': true });
              if (! dialog.visible()) {
                dialog.show();
              } else {
                dialog.destroy();
              }
            }
          },
          
          lights: {
            action: function() {
              alert('lights');
            }
          },
          
          expand: {
            action: function() {
              if (isFullScreen()) {
                // collapse to a regular-sized window
                window.resizeTo(width, height);
                if (originalX) {
                  window.moveTo(originalX, originalY);
                } else {
                  window.moveTo(200, 200);
                }
              } else {
                // expand to fullscreen
                if (inPopout) {
                  if (! isFullScreen()) {
                    // save previous window position.
                    if (document.all) {
                      originalX = window.screenLeft;
                      originalY = window.screenTop;
                    } else {
                      originalX = window.screenX;
                      originalY = window.screenY;
                    }
                    
                    window.moveTo(0,0);
                    window.resizeTo(screen.availWidth, screen.availHeight);
                    var fullScreenDetectTimer = setTimeout(function() {
                      if (!isFullScreen()) {
                        alert('It seems like your browser isn\'t permitting us to make this window fullscreen.  Please try adjusting your browser\'s JavaScript settings to allow scripts to "resize existing windows."');
                      } else {
                        if (false && window.onresize && Prototype.Browser.IE) {
                          window.resizeTo(screen.availWidth-10, screen.availHeight);
                          var ieResizeTimeout = setTimeout(function() {
                            window.resizeTo(screen.availWidth, screen.availHeight);
                          }, 100);
                        }
                      }
                    }, 1000);
                    window.moveTo(0,0);
                  }
                } else {
                  openPopout(screen.availWidth, screen.availHeight);
                }
              }
            }
          },
          
          popout: {
            registered: function() {
              if (inPopout) {
                toolbar.icon('popout').hide();
              }
            },
            action: function() {
              openPopout(width, height+toolbarHeight);
            }
          }
        };
        
        var panels = function(name) {
          var node = $('panel_' + hash + '_' + name);
          var statusNode = $('status_' + hash + '_' + name);
          var errorClass = 'error';
          var successClass = 'success';
          
          var currentlyOpenPanel = function() {
            // TODO
          };
          
          var visible = function() {
            if (node) {
              return 'block' === node.style.display; // since visible() isn't working
            }
            return false;
          };
          
          var reset = function() {
            var resetFn = buttons[name].resetPanel;
            if (resetFn) {
              resetFn();
            }
          };
          
          var show = function() {
            if (node) {
              reset();
              node.style.display = 'block'; // .show() isnt working
            }
          };
          
          var hide = function() {
            if (node) {
              node.hide();
            }
          };
          
          var updateInfo = function(msg) {
            statusNode.removeClassName(errorClass);
            statusNode.removeClassName(successClass);
            statusNode.update(msg);
          };
          
          var updateSuccess = function(msg) {
            if (msg) {
              statusNode.removeClassName(errorClass);
              statusNode.update(msg);
              var highlight = new Effect.Highlight(statusNode);
            }
            
            var fadeTimer = setTimeout(function() {
              var fade = new Effect.Fade(node, {duration: 1.0});
            }, 1000);
          };
          
          var updateFailure = function(msg) {
            statusNode.removeClassName(successClass);
            statusNode.addClassName(errorClass);
            statusNode.update(msg);
          };
                    
          if (node) { // TODO think about replicating this technique elsewhere
            return {
              node: node,
              visible: visible,
              reset: reset,
              show: show,
              hide: hide,
              initialize: reset,
              updateInfo: updateInfo,
              updateSuccess: updateSuccess,
              updateFailure: updateFailure
            };
          } else {
            return null;
          }
        };
        var toolbar = function() {
          var toolbar = $('toolbar_' + hash);
          var disabledButtons = {};
          var hiddenButtons = {};

          var icon = function(name) {
            var disabledName = name + '_disabled';
            var idSuffix = '_' + hash + '_' + name;
            var node = $('toolbar' + idSuffix);
            var panel = panels(name);
            var closeNode = $('close' + idSuffix);
            var tooltipNode = $('tooltip' + idSuffix);
            var alternateTooltipNode = $('tooltip' + idSuffix + "_toggled");
            var toggleable = !!alternateTooltipNode;
            var registered = buttons[name].registered;
            var action = buttons[name].action;
            
            /* icon methods */
            
            var hidden = function() {
              return !! hiddenButtons[name];
            };
            var visible = function() {
              return ! hidden();
            };
            
            var disabled = function() {
              return !! disabledButtons[name];
            };
            var enabled = function() {
              return ! disabled();
            };
            
            var hide = function() {
              if (node) {
                node.hide();
              }
            };
            
            var show = function() {
              if (node) {
                node.show();
              }
            };
            
            var toggled = function() {
              return node && node.hasClassName('toggled');
            };
            
            var toggle = function() {
              if (toggleable) {
                if (toggled()) {
                  node.removeClassName('toggled');
                } else {
                  node.addClassName('toggled');
                }
              }
            };
            
            var disable = function() {
              disabledButtons[name] = true;
              
              if (node && node.hasClassName(name)) {
                node.removeClassName(name);
                node.addClassName(disabledName);
              }
            };
            
            var enable = function() {
              disabledButtons[name] = undefined;
              
              if (node && node.hasClassName(disabledName)) {
                node.removeClassName(disabledName);
                node.addClassName(name);
              }
            };
            
            var register = function() {
              if (node) {
                if (action) {
                  var toolbar = this;
                  // TODO: maybe use event bubbling instead of this. (i.e., put one listener on the whole toolbar)
                  node.observe('click', function(e) {
                    e.stop();
                    if (toolbar.enabled()) {
                      if (tooltipNode) {
                        tooltipNode.hide();
                        if (toggleable) {
                          alternateTooltipNode.hide();
                        }
                      }
                      action(e);
                    }
                  });
                  
                  if (panel) { // TODO this should be done in a panel object
                    panel.initialize();
                  }
                }
                
                if (tooltipNode) {
                  var timeout = null;
                  node.observe('mouseover', function(e) {
                    var currTtn = toggled() ? alternateTooltipNode : tooltipNode;
                    clearTimeout(timeout);
                    if (! currTtn.visible()) {
                      timeout = setTimeout(function() {
                        var appear = new Effect.Appear(currTtn, {duration: 0.1});
                      }, 400);
                    }
                  });
                  node.observe('mouseout', function(e) {
                    var currTtn = toggled() ? alternateTooltipNode : tooltipNode;
                    if (! currTtn.visible()) {
                      clearTimeout(timeout);
                    } else {
                      var fade = new Effect.Fade(tooltipNode, {duration: 0.1});
                      if (toggleable) {
                        var fade2 = new Effect.Fade(alternateTooltipNode, {duration: 0.1});
                      }
                    }
                  });
                }
                
                if (closeNode) { // TODO this should be done in a panel object                                    
                  closeNode.observe('click', function(e) {
                    panel.hide();
                  });
                }
                
                if (registered) {
                  registered();
                }
              }
            };
            
            return {
              node: node,
              hidden: hidden,
              visible: visible,
              hide: hide,
              show: show,
              toggle: toggle,
              toggled: toggled,
              disabled: disabled,
              enabled: enabled,
              disable: disable,
              enable: enable,
              register: register
            };
          };
          var icons = $H(buttons).keys().map(function(name) {
            return icon(name);
          });
          
          var availableSpace = function() {
            if (!toolbar) {
              return 0;
            }
            var breathingRoomWidth = 2;
            var attributionWidth = toolbar.select('.casttv_attribution')[0].getWidth();
            var leftRightContainer = toolbar.select('.buttons_previous_next')[0];

            var leftRightContainerVisible = leftRightContainer.getStyle('display') != 'none'; // .visible() isn't working
            var leftRightContainerWidth = leftRightContainerVisible ? leftRightContainer.getWidth() : 0;
            var iconsWidth = toolbar.select('.toolbar_icons')[0].getWidth();

            return toolbar.getWidth() - attributionWidth - leftRightContainerWidth - iconsWidth - breathingRoomWidth;
          };
          
          /* toolbar methods */
          
          var searchBoxWidth = function() {
            if (!toolbar) {
              return 0;
            }
            
            var maxSearchWidth = 415;
            var searchWidth = Math.min(maxSearchWidth, availableSpace());
            var searchButtonSpace = toolbar.select('.player_search_box_right')[0].getWidth();

            return searchWidth - searchButtonSpace;
          };
          
          var warningCalloutWidth = function() {
            var spacerWidth = 8;
            return availableSpace() - (spacerWidth * 2); // left and right spacers
          };
          
          var hideButtons = function() {
            $A(arguments).each(function(arg) {
              icon(arg).hide();
            });
          };
          
          var showButtons = function() {
            $A(arguments).each(function(arg) {
              icon(arg).show();
            });
          };
          
          var disableButtons = function() {
            $A(arguments).each(function(arg) {
              icon(arg).disable();
            });
          };
          
          var enableButtons = function() {
            $A(arguments).each(function(arg) {
              icon(arg).enable();
            });
          };
          
          var hide = function() {
            toolbar.hide();
          };
          
          var show = function() {
            toolbar.show();
          };
          
          var register = function() {
            icons.each(function(icon) {
              icon.register();
            });
          };
          
          return {
            icon: icon,
            icons: icons,
            searchBoxWidth: searchBoxWidth,
            warningCalloutWidth: warningCalloutWidth,
            hideButtons: hideButtons,
            hideButton: hideButtons,
            showButtons: showButtons,
            showButton: showButtons,
            disableButtons: disableButtons,
            disableButton: disableButtons,
            enableButtons: enableButtons,
            enableButton: enableButtons,
            hide: hide,
            show: show,
            register: register
          };
        }();

        var embed = function() {
          var embedNode = node.select('.playback_embed')[0];
          var closeButtonNode = node.select('.playback_embed .close_embed')[0];
          if (closeButtonNode) {
            closeButtonNode.observe('click', function(e) {
              var thisPlayback = CastTV.playbacks(hash);
              if (thisPlayback) {
                thisPlayback.hide();
                if (postToggleCallback) {
                  postToggleCallback(false);
                }
              }
            });
          }
          var hide = function() {
            embedNode.hide();
            if (Prototype.Browser.IE) {
              stashContent();
            }
          };
          var show = function() {
            // reload content if it's stashed
            reloadContent();
            embedNode.show();
            CastTV.scrollIntoView(embedNode);
          };
          var visible = function() {
            return embedNode && embedNode.visible();
          };
          return {
            hide: hide,
            show: show,
            visible: visible
          };
        }();
        
        /* playback methods */
        
        var showButtons = function() {
          toolbar.showButtons.apply(toolbar, arguments);
        };
        
        var hideButtons = function() {
          toolbar.hideButtons.apply(toolbar, arguments);
        };
        
        var disableButtons = function() { // delegate to toolbar
          toolbar.disableButtons.apply(toolbar, arguments);
        };
        
        var enableButtons = function() { // delegate to toolbar
          toolbar.enableButtons.apply(toolbar, arguments);
        };
        
        var adoptSiblings = function() {
          var siblings = CastTV.getSiblingGroup(hash);
          var prevNextContainerNode = node.select('.buttons_previous_next')[0];
          
          if (siblings && siblings.length > 1) {
            if (prevNextContainerNode) {
              prevNextContainerNode.show();
            }
            siblings.map(function(sibHash,index) {
              if (hash == sibHash) {
                if (index > 0) {
                  prevHash = siblings[index-1];
                  enableButtons('previous');
                } else if (0 === index) {
                  prevHash = siblings[siblings.length - 1];
                  enableButtons('previous');
                }
                if (index < siblings.length -1) {
                  nextHash = siblings[index+1];
                  enableButtons('next');
                } else if (siblings.length - 1 == index) {
                  nextHash = siblings[0];
                  enableButtons('next');
                }
                // do *not* put break here.  it BREAKs cross-window js communication
              }
            });
          } else {
            if (prevNextContainerNode) {
              prevNextContainerNode.hide();
            }
          }
          return siblings;
        };
        
        var setToggleable = function() {
          toggleable = true;
        };
        
        var isToggleable = function() {
          return toggleable;
        };
        
        var setPostToggleCallback = function(callback) {
          postToggleCallback = callback;
        };
        
        var showWarningCallout = function() {
          // TODO this width needs to be max available space.  needs to reuse the searchbox width calc. function.
          // this function needs to be moved into playback.
          node.select('.warning_callout_center')[0].setStyle({'width': ''+toolbar.warningCalloutWidth()+'px'});
          node.select('.warning_callout_container')[0].show();
        };
        
        // TODO it would be nice if we could get this information in the form of playback constructor params
        var setVideoDimensions = function(w,h) {
          width = w;
          height = h;
        };
        
        var hide = function() {
          embed.hide();
          toolbar.hide();
        };
        
        var show = function() {
          embed.show();
          toolbar.show();
        };
        
        var visible = function() {
          return embed.visible();
        };
        
        // this variable currently isn't being used.  the click observe is, though.
        var videoBlock = function() {
          var blockNode = node.select('.playback_info>.video_block')[0];
          var otherVisiblePlayback = function() {
            return $H(memoizedPlaybacks).values().find(function(pb) { return pb.visible(); });
          };
          
          if (blockNode) {
            blockNode.observe('click', function(e) {
              if (toggleable) {
                e.stop();
                var isVisible = visible();
                if (isVisible) {
                  hide();  // playback.show() 
                } else {
                  var otherPlayback = otherVisiblePlayback();
                  if (otherPlayback) {
                    otherPlayback.hide();
                  }
                  show();  // playback.show()
                }
                
                if (postToggleCallback) {
                  postToggleCallback(!isVisible);
                }
              }
            });
          }
          
          return {
          };
        }();
        
        var register = function() {
          adoptSiblings();
          toolbar.register();
        };
        
        return {
          hash: hash,
          node: node,
          // TODO eventually: player-specific playAt, pause, continue, etc.  in a 'player' object or something
          panels: panels,
          toolbar: toolbar,
          stashContent: stashContent,
          reloadContent: reloadContent,
          hideButtons: hideButtons,
          hideButton: hideButtons,
          showButtons: showButtons,
          showButton: showButtons,
          disableButtons: disableButtons,
          disableButton: disableButtons,
          enableButtons: enableButtons,
          enableButton: enableButtons,
          setVideoDimensions: setVideoDimensions,
          adoptSiblings: adoptSiblings,
          setToggleable: setToggleable,
          isToggleable: isToggleable,
          setPostToggleCallback: setPostToggleCallback,
          showWarningCallout: showWarningCallout,
          hide: hide,
          show: show,
          visible: visible,
          register: register
        };
      };
      
      existing = memoizedPlaybacks[hash] = createPlayback();
      
      // TODO this should go in a Playback constructor
      existing.register(); // register any events for this newly-created playback
    }
    return existing;
  };

  /* tabs stuff */
	
	var TabSet = function() {

    var dropdownNodes = [];

    var anyDropdownVisible = function() {
      var found = false;
      dropdownNodes.each(function(ddn) {
        if (ddn.visible()) {
          found = ddn;
          return $break;
        }
      });
      return found;
    };

    var Tab = function(tabNode, dropdownNode) {
      
      var dropdownVisible = function() {
        return dropdownNode.visible();
      };

      var showDropdown = function() {
        dropdownNode.show();
      };

      var hideDropdown = function() {
        dropdownNode.hide();
      };

      (function() {
        hideDropdown();

        var delayedOpen = null;
        var delayedClose = null;
        
        var delayClose = function() {
          delayedClose = setTimeout(function() {
            if (dropdownVisible) {
              // if we weren't "transitioned" to another tab yet
              hideDropdown();
            }
          }, 50);
        };
        
        tabNode.observe('mouseover', function() {
          if (! dropdownVisible()) {
            var visibleDropdown = anyDropdownVisible();
            if (visibleDropdown) {
              // previous menu is in delayed closing state.  do a fast transition
              visibleDropdown.hide();
              showDropdown();
            } else {
              // delayed transition
              delayedOpen = setTimeout(function() {
                showDropdown();
              }, 200);
            }
          } else {
            clearTimeout(delayedClose);
          }
        });
        
        tabNode.observe('mouseout', function() {
          if (! dropdownVisible()) {
            clearTimeout(delayedOpen);
          } else {
            delayClose();
          }
        });
        
        dropdownNode.observe('mouseover', function() {
          clearTimeout(delayedClose);
          showDropdown();
        });
        
        dropdownNode.observe('mouseout', function() {
          delayClose();
        });
      })();

      return {
        showDropdown: showDropdown,
        hideDropdown: hideDropdown
      };
    };
    
    var tabSet = function() {
      var listItems = $$('#tabs>ul>li');
      if (! listItems) {
        return [];
      }
      // populate dropdownNodes before any individual Tab is created
      dropdownNodes = listItems.map(function(li) {
        return li.getElementsBySelector('.tab_menu_panel')[0];
      });
      
      return dropdownNodes.map(function(dropdownNode) {
        var tabNode = dropdownNode.previousSiblings()[0];
        return new Tab(tabNode, dropdownNode);
      });
    }();
    
    return tabSet;
  };

  var memoizedTabSet = null;
  var tabs = function() {
    if (! memoizedTabSet) {
      memoizedTabSet = new TabSet();
    }
    return memoizedTabSet;
  };
  
  return {
    inPopout: inPopout,
    inEmbed: inEmbed,
    DateHelper: DateHelper,
    scrollIntoView: scrollIntoView,
    validateEmail: validateEmail,
    validateEmails: validateEmails,
    addBookmark: addBookmark,
    installBrowserSearchEngine: installBrowserSearchEngine,
    trackUtilizedSuggestion: trackUtilizedSuggestion,
    get: get,
    update: update,
    fromUs: fromUs,
    processAnchorRoute: processAnchorRoute,
    isSafeSearchOff: isSafeSearchOff,
    searchUrl: searchUrl,
    openSearchPage: openSearchPage,
    resizeEmbed: resizeEmbed,

    registerSiblingGroup: registerSiblingGroup,
    getSiblingGroup: getSiblingGroup,

    thumbnails: thumbnails,
    containers: containers,
    playbacks: playbacks,
    tabs: tabs
  };
}();


CastTV.VideoGroup = Class.create({
  initialize: function(container, options) {
    this.container = $(container);
    if(!this.container) {
      throw "CastTV.VideoGroup could not find the container element: " + container;
    }
    CastTV.VideoGroup.instances.push(this);    
        
    this.activeBlock = null;
    this.blocks = $A([]);
    
    this.options = {
      beforeChange: Prototype.emptyFunction,
      afterChange: Prototype.emptyFunction,
      defaultBlock: null,
      blockKeyRegex: /video_block_(\w+)/,
      blockSelector: 'ul>li>div.video_block',
      transitionLinkClassName: 'transition',
      transitionContainer: null
    };
    Object.extend(this.options, options || {});
    
    this.container.select(this.options.blockSelector).each(function(block){
      this.addBlock(block);
    }.bind(this));
    
    if (this.options.defaultBlock) {
      this.setActiveBlock(this.options.defaultBlock);
    }
  },
  
  addBlock: function(block, nextBlock) {
    var nextIndex = nextBlock ? this.blocks.indexOf(nextBlock) : 0;
    this.blocks.insert(nextIndex-1, block); // inserts at end when nextIndex is 0

    block.key = block.id.match(this.options.blockKeyRegex)[1];

    block.select('.' + this.options.transitionLinkClassName).each(function(transitionLink) {
      transitionLink.observe('click', function(e) {
        e.stop();
        this.setActiveBlock(block);
      }.bindAsEventListener(this));
    }.bind(this));
    block.observe('mouseover', function() {
      this.addClassName('video_block_hover');
    });
    block.observe('mouseout', function() {
      this.removeClassName('video_block_hover');
    });
  },

  removeBlock: function(block) {
    this.blocks = this.blocks.without(block);
  },

  setActiveBlock: function(block) {
    if (!block && typeof(block) == 'undefined') {
      return;
    } else if (typeof(block) == 'string') {
      this.setActiveBlock(this.blocks.find(function(_block) {
        return _block.key == block;
      }));
    } else if (typeof(block) == 'number') {
      this.setActiveBlock(this.blocks[block]);
    } else {
      var oldBlock = this.activeBlock;      
      if (false === this.options.beforeChange.bind(this)(oldBlock, block)) {
        return false;
      }

      CastTV.containers(this.options.transitionContainer).transitionTo(block.key, function() {
        this._blockChangedTo(block);
        this.options.afterChange.bind(this)(oldBlock, block);
      }.bind(this));
    }
  },
  
  next: function() {
    this.blocks.each(function(block, i) {
      if(this.activeBlock == block && this.blocks[i + 1]){
        this.setActiveBlock(this.blocks[i + 1]);
        throw $break;
      }
    }.bind(this));
  },
  
  previous: function() {
    this.blocks.each(function(block, i) {
      if(this.activeBlock == block && this.blocks[i - 1]){
        this.setActiveBlock(this.blocks[i - 1]);
        throw $break;
      }
    }.bind(this));
  },
  
  first: function() {
    this.setActiveBlock(this._firstBlock());
  },
  
  last: function() {
    this.setActiveBlock(this._lastBlock());
  },
    
  // separated this out into its own function because we want to be able to decorate it in subclasses
  _blockChangedTo: function(block) {
    this.activeBlock = block;
  },
  
  _firstBlock: function() {
    return this.blocks.first();
  },
  
  _lastBlock: function() {
    return this.blocks.last();
  }
  
});

Object.extend(CastTV.VideoGroup, {
  blockWidth: 115,
  visibleBlocks: 4,
  instances: [],
  findByBlockId: function(id) {
    return CastTV.VideoGroup.instances.find(function(videoGroup) {
      return videoGroup.blocks.find(function(block) {
        return block.key === id;
      });
    });
  }
});

CastTV.ScrollableVideoGroup = Class.create(CastTV.VideoGroup, {
  initialize: function($super, container, options) {
    var scrollingOptions = {
      viewportSelector: 'div.related_h_viewport',
      scrollerControlsSelector: 'div.scroller_controls',
      pageLinksSelector: 'div.scroller_controls ul li a',
      beforeScroll: Prototype.emptyFunction,
      afterScroll: Prototype.emptyFunction,
      leftButtonSelector: 'a.previous_set',
      rightButtonSelector: 'a.next_set',
      leftButtonDisabledClassName: 'previous_set_disabled',
      rightButtonDisabledClassName: 'next_set_disabled',
      currentPageClassName: 'current_page',
      scrollDist: CastTV.VideoGroup.blockWidth * CastTV.VideoGroup.visibleBlocks,
      scrollTime: 0.3
    };
    Object.extend(scrollingOptions, options || {});
    $super(container, scrollingOptions);
    
    this.viewport = this.container.select(this.options.viewportSelector)[0];
    this.scrollerControls = this.container.select(this.options.scrollerControlsSelector)[0];
    this.leftButton = this.container.select(this.options.leftButtonSelector)[0];
    this.rightButton = this.container.select(this.options.rightButtonSelector)[0];
    this.currentlyScrolling = false;
    this.scrollQueue = [];
    
    this._regeneratePagination();
    if (this.leftButton) {
      this.leftButton.observe('click', this.scrollLeft.bindAsEventListener(this));
    }
    if (this.rightButton) {
      this.rightButton.observe('click', this.scrollRight.bindAsEventListener(this));
    }
    
    this._updateButtonStates();
    new PeriodicalExecuter(function(pe) { pe.stop(); this._updateButtonStates(); }.bind(this), 1); // IE6 workaround
  },
  
  scrollLeft: function(e) {
    if (e) { e.stop(); }
    if (this._canScrollLeft() && !this.currentlyScrolling) {
      this._scrollVertically(-this.options.scrollDist);
    }
  },
  
  scrollRight: function(e) {
    if (e) { e.stop(); }
    if (this._canScrollRight() && !this.currentlyScrolling) {
      this._scrollVertically(this.options.scrollDist);
    }
  },
  
  _regeneratePagination: function() {
    if (this.scrollerControls) {
      var oldUl = this.scrollerControls.select('ul')[0];
      if (oldUl) { this.scrollerControls.removeChild(oldUl); }
    
      var totalPages = this._blockPage(this.blocks.last()) + 1;
      var ul = new Element('ul');

      for (var i = 0; i < totalPages; ++i) (function(page) {
        var a = new Element('a', { 'href': '#' }).update('&bull;');
        a.observe('click', function(e) {
          e.stop();
          this._scrollToPageImmediately(page);
        }.bindAsEventListener(this));
        var li = new Element('li');
        li.appendChild(a);
        ul.appendChild(li);
      }.bind(this))(i);
      
      this.scrollerControls.appendChild(ul);
      
      this.pageLinks = this.container.select(this.options.pageLinksSelector);
    }
  },
  
  _canScrollLeft: function() {
    return this._firstBlock().cumulativeScrollOffset()[0] > 0;
  },
  
  _canScrollRight: function() {
    var lastBlock = this._lastBlock();
    var lastBlockLeft = lastBlock.cumulativeOffset()[0] - lastBlock.cumulativeScrollOffset()[0];
    var lastBlockWidth = lastBlock.getDimensions().width;    
    var lastBlockTotalDistanceLeft = lastBlockLeft + lastBlockWidth;
    var outOfBoundsLeft = this.viewport.cumulativeOffset()[0] + this.viewport.getDimensions().width;

    return lastBlockTotalDistanceLeft > outOfBoundsLeft;
  },
  
  _updateButtonStates: function() {
    if (this.leftButton) {
      if (this._canScrollLeft()) {
        this.leftButton.removeClassName(this.options.leftButtonDisabledClassName);
      } else {
        this.leftButton.addClassName(this.options.leftButtonDisabledClassName);
      }
    }
    if (this.rightButton) {
      if (this._canScrollRight()) {
        this.rightButton.removeClassName(this.options.rightButtonDisabledClassName);
      } else {
        this.rightButton.addClassName(this.options.rightButtonDisabledClassName);
      }
    }
    if (this.pageLinks && this.pageLinks.length > 0) {
      var currentPage = this._currentPage();
      this.pageLinks.each(function(link, index) {
        if (index == currentPage) {
          link.addClassName(this.options.currentPageClassName);
        } else {
          link.removeClassName(this.options.currentPageClassName);
        }
      }.bind(this));
    }
  },
  
  _scrollVertically: function(xAmount) {
    if (this.currentlyScrolling && this.scrollQueue.length > 0) {
      // unexpected situation.  but if scrollQueue has something, then it is safe to drop this current request
      return;
    }

    this.currentlyScrolling = true;
    this.options.beforeScroll.bind(this)();
    var manualScroll = new Effect.Scroll(this.viewport, { x: xAmount, afterFinish: function(){
      this._updateButtonStates();
      this.currentlyScrolling = false;
      this.options.afterScroll.bind(this)();
      if (this.scrollQueue.length > 0) {
        // instead of popping, we only ever care about the newest thing, so shift.
        this._scrollToBlock(this.scrollQueue.shift());
        this.scrollQueue = [];
      }
    }.bind(this), duration: this.options.scrollTime });
  },
    
  // returns null or a zero-indexed page
  _blockPage: function(block) {
    var blockIndex = null
    this.blocks.find(function(_block, index) {
      if (_block == block) {
        blockIndex = index;
        return true;
      }
      return false;
    });
    
    if (blockIndex) {
      return parseInt(blockIndex / CastTV.VideoGroup.visibleBlocks);
    }
    return null;
  },
  
  _currentPage: function() {    
    return parseInt(this.viewport.cumulativeScrollOffset()[0] / this.options.scrollDist);
  },
  
  _scrollToPage: function(destPage) { // TODO move queueing logic here    
    var currPage = this._currentPage();
    if (currPage != destPage) {
      this._scrollVertically((destPage - currPage) * this.options.scrollDist);
    }
  },
  
  _scrollToBlock: function(block) {
    this._scrollToPage(this._blockPage(block));
  },
  
  // takes higher precedence than transitionTo callbacks
  _scrollToPageImmediately: function(destPage) {    
    // if currentlyScrolling, hijack (unshift) the queue to go to a (the first, arbitrarily) block on destPage (b/c the queue is currently block-based).
    if (this.currentlyScrolling) {
      this.scrollQueue.unshift(this.blocks[destPage*CastTV.VideoGroup.visibleBlocks]);
    } else {
      this._scrollToPage(destPage);
    }
  },
  
  // callback once a transitionTo is complete
  _blockChangedTo: function($super, block) {
    $super(block);
    if (this.currentlyScrolling) {
      this.scrollQueue.push(block); // defer scroll
    } else {
      this._scrollToBlock(block);
    }
  }
  
});

CastTV.TweetedVideo = Class.create({
  initialize: function(hash, options) {
    this.hash = hash;
    this.options = {
      parentUlId: 'tweeted_videos',
      hiddenItemClass: 'temporarily_hidden',
      tweetedVideoClass: 'tweeted_video_block',
      tweetClass: 'block_tweet',
      tweetCountSelector: '.tweet_count',
      totalTweetCountSelector: '.tweet_count > span',
      tweetContainerSelector: 'ul.block_tweets',
      tweetSelector: 'ul.block_tweets>li.block_tweet',
      tweetHiddenTimesSelector: '.tweet_meta input',
      tweetTimeAgoLinksSelector: '.tweet_meta .tweet_meta_posted a',
      fetchMoreLinkSelector: '.toggle_tweets a.tweet_expand',
      collapseLinkSelector: 'a.tweet_collapse',
      moreTweetsPath: '/twitter/more/',
      fetchMoreSpinnerSelector: 'div.expand_spinner',
      collapsedMaxTweetsVisible: 3,
      tweetNavAnimationDuration: 0.2
    };
    Object.extend(this.options, options || {});

    this.parentUl = $(this.options.parentUlId);

    if (this.options.nodeHtml) {
      this._buildFromHtml(this.options.nodeHtml);
    } else {
      // fallback to using the playback node on pages other than the tweets page.
      //   we won't be using any html rebuilding or dom removal or anything so it's ok for now
      this.node = $('tweeted_video_'+this.hash) || $('outer_playback_'+this.hash);
      if(!this.node) {
        throw 'CastTV.TweetedVideo could not find the node element: ' + node;
      }
    }
    CastTV.TweetedVideo.instances.set(this.hash, this);

    this._attachNodeEvents();

    this.latestTweetTime = (new Date(this._timeStringForIndexedTweet(0))-0) / 1000;
    this.currentlyFetchingTweets = false;
  },

  _timeStringForIndexedTweet: function(i) {
    return this.node.select(this.options.tweetHiddenTimesSelector)[i].value;
  },

  _getFetchMoreLink: function() {
    return this.node.select(this.options.fetchMoreLinkSelector)[0];
  },

  _getCollapseLink: function() {
    return this.node.select(this.options.collapseLinkSelector)[0];
  },

  _getFetchMoreSpinner: function() {
    return this.node.select(this.options.fetchMoreSpinnerSelector)[0];
  },

  _getTweetTimeAgoLinks: function() {
    return this.node.select(this.options.tweetTimeAgoLinksSelector);
  },

  _totalTweetCount: function() {
    return parseInt(this.node.select(this.options.totalTweetCountSelector)[0].innerHTML.replace(',', ''));
  },

  _moreTweetsAvailable: function() {
    return this._totalTweetCount() !== this._visibleTweetsCount();
  },
  
  _updateTweetNavigation: function() {
    if (!this._moreTweetsAvailable()) {
      this._getFetchMoreLink().fade({duration: this.options.tweetNavAnimationDuration});
    }
    if (this._visibleTweetsCount() > this.options.collapsedMaxTweetsVisible) {
      this._getCollapseLink().appear({duration: this.options.tweetNavAnimationDuration});
    }
  },

  _attachNodeEvents: function() {
    // Once we have a proper Playback/Video class in place, we'd prefer to have TweetedVideo subclass that and decorate the event callback
    //   as opposed to resorting to this type of hook thing
    CastTV.playbacks(this.hash).setPostToggleCallback(function(visibility) {
      CastTV.TweetDaemon.instance.updateNotifications(visibility);
    });

    var fetch = function(e) {
      e.stop();
      var tweets = this.node.select(this.options.tweetSelector);
      var someTweetsHidden = tweets.find(function(tweet){
        return !tweet.visible();
      });
      if (someTweetsHidden) {
        tweets.slice(this.options.collapsedMaxTweetsVisible).each(function(tweet) {
          tweet.appear({duration: this.options.tweetNavAnimationDuration});
        }.bind(this));
        this._updateTweetNavigation();
      } else if (!this.currentlyFetchingTweets && this._moreTweetsAvailable()) {
        this.fetchMoreTweets();
      }
    }.bindAsEventListener(this);

    this._getFetchMoreLink().observe('click', fetch);

    var collapseLink = this._getCollapseLink();
    collapseLink.observe('click', function(e) {
      e.stop();
      var tweets = this.node.select(this.options.tweetSelector);
      tweets.each(function(tweet, index){
        if (index >= this.options.collapsedMaxTweetsVisible) {
          tweet.hide();
        }
      }.bind(this));
      
      if (this._visibleTweetsCount() > this.options.collapsedMaxTweetsVisible) {
        this._getFetchMoreLink().appear({duration: this.options.tweetNavAnimationDuration});
      }      
      collapseLink.hide();
    }.bindAsEventListener(this));
  },

  _buildFromHtml: function(html) {
    var li = new Element('li', { 'id': 'tweeted_video_'+this.hash, 'class': this.options.hiddenItemClass+' '+this.options.tweetedVideoClass, 'style': 'display: none;' });
    li.update(html);
    this.node = li;
    this.parentUl.insertBefore(li, this.parentUl.firstChild);
  },

  detachNode: function() {
    this.parentUl.removeChild(this.node);
    this.node = null;
  },

  reinsert: function(html) {
    // We are currently immediately removing replaced videos
    this.detachNode();
    this._buildFromHtml(html);
    this._attachNodeEvents();
  },

  updateTimestamp: function() {
    var timeAgoLinks = this._getTweetTimeAgoLinks();
    
    // CastTV.DateHelper.time_ago_in_words_with_parsing(  );
    
    timeAgoLinks.each(function(link, index) {
      var oldContents = link.innerHTML;
      var utcTimeStr = this._timeStringForIndexedTweet(index);
      var humanTime = CastTV.DateHelper.time_ago_in_words_with_parsing(utcTimeStr);
      link.update(humanTime);
      
      if (oldContents !== humanTime) {
        link.highlight({duration: 1.5});
      }
    }.bind(this));
  },

  _visibleTweetsCount: function() {
    return this.node.select(this.options.tweetSelector).length;
  },

  _moreTweetsUrl: function() {
    return this.options.moreTweetsPath + this.hash + '/' + this.latestTweetTime + '/' + this._visibleTweetsCount() + '.json';
  },

  _consolidateNewTweets: function(newTweets) {
    $A(newTweets).each(function(tweetHtml) {
      var li = new Element('li', { 'class': this.options.tweetClass, 'style': 'display: none;' });
      li.update(tweetHtml);
      this.node.select(this.options.tweetContainerSelector)[0].appendChild(li);
      li.appear({duration: 1.0});
    }.bind(this));
    this._updateTweetNavigation();
  },

  fetchMoreTweets: function() {
    this.currentlyFetchingTweets = true;
    this._getFetchMoreSpinner().appear();
    CastTV.get(this._moreTweetsUrl(), function(transport) {
      if (transport.status >= 200 && transport.status < 300) {
        var newTweets = eval('('+transport.responseText+')');
        this._consolidateNewTweets(newTweets);
        this._getFetchMoreSpinner().fade();
        this.currentlyFetchingTweets = false;
      }
    }.bind(this));
  },

  isHidden: function() {
    return !!this.node.hasClassName(this.options.hiddenItemClass);
  },

  _firstTweetNode: function() {
    return this.node.select(this.options.tweetSelector)[0];
  },

  show: function(callback) {
    var appear = new Effect.Appear(this.node, {duration: 1.0, afterFinish: callback});
    var highlight = new Effect.Highlight(this._firstTweetNode(), {duration: 2.0});
    
    this.node.removeClassName(this.options.hiddenItemClass);
  }
});

Object.extend(CastTV.TweetedVideo, {
  options: {
    videoContainerSelector: 'ul#tweeted_videos>li',
    playbackEmbedSelector: '.playback_embed'
  },
  
  instances: new Hash(),
  
  nodes: function() {
    return $$(CastTV.TweetedVideo.options.videoContainerSelector);
  },
  
  hashFromNode: function(node) {
    return node.id.match(/tweeted_video_(\w+)/)[1];
  },
  
  registerAll: function() {
    CastTV.TweetedVideo.nodes().each(function(node) {
      new CastTV.TweetedVideo(CastTV.TweetedVideo.hashFromNode(node));
    });
  },
  
  get: function(hash) {
    return CastTV.TweetedVideo.instances.get(hash);
  },
  
  remove: function(hash) {
    CastTV.TweetedVideo.instances.get(hash).detachNode();
    CastTV.TweetedVideo.instances.unset(hash);
  },
  
  replace: function(hash, html) {
    CastTV.TweetedVideo.get(hash).reinsert(html);
  },
  
  updateTimestamps: function() {
    CastTV.TweetedVideo.instances.values().each(function(tweetedVideo) {
      tweetedVideo.updateTimestamp();
    });
  },
  
  isEmbedVisible: function() {
    return 'undefined' !== typeof $$(CastTV.TweetedVideo.options.playbackEmbedSelector).find(function(elt) {
      return elt.visible();
    });
  }
});

CastTV.TweetDaemon = Class.create({
  initialize: function(options) {
    this.options = {
      loopInterval: 30,
      tweetHomePath: '/twitter',
      tweetCountPath: '/twitter/count/',
      updatesPath: '/twitter/data/',
      topicParameter: 'topic',
      maxParameter: 'max',
      pageParameter: 'p',
      autoUpdateParameter: 'auto',
      paginationPreviousText: 'previous',
      paginationNextText: 'next',
      updateNotesSelector: '.notification .update',
      updateCountsSelector: '.notification .update span.count',
      updateRefreshesSelector: '.notification .update a.refresh_page',
      warningNotesSelector: '.notification .warning',
      autoUpdateRadioSelector: '.autoupdate_radio',
      autoUpdateRadioOnValue: 'on',
      autoUpdateContainerSelector: '.subnav_autoupdate',
      updateSpinnerSelector: '.subnav_autoupdate .casttv_spinner_sm',
      topOfPageSelector: '#header',
      maxVideosPerPage: 20,
      updatesFrozen: false,
      currentPage: 1,
      paginationLinksSelector: 'div#pagination > ul > li > a',
      topic: null,
      updateTweetsOnly: false
    };
    Object.extend(this.options, options || {});

    if (CastTV.TweetDaemon.instance) {
      throw 'an instance of CastTV.TweetDaemon already exists';
    } else {
      CastTV.TweetDaemon.instance = this;
    }

    this.updater = null;
    this.lastUpdated = null;
    this.autoUpdatesPaused = false;
    this.delayedConsolidationData = null;

    CastTV.TweetedVideo.registerAll();
    CastTV.TweetedVideo.updateTimestamps();

    this._registerNotifications();
    this._registerNavigation();
  },

  stop: function() {
    this.updater.stop();
    this.updater = null;
  },

  restart: function() {
    this.stop();
    this._startUpdater();
  },

  _updatePagination: function() {
    $$(this.options.paginationLinksSelector).each(function(link) {
      var text = link.innerHTML;
      var page = null;
      if (text.match(this.options.paginationPreviousText)) {
        page = this.options.currentPage - 1;
      } else if (text.match(this.options.paginationNextText)) {
        page = this.options.currentPage + 1;
      } else {
        page = parseInt(text);
      }

      link.href = this.options.tweetHomePath + '?' +
          (this.options.topic ? (this.options.topicParameter + '=' + encodeURIComponent(this.options.topic) + '&') : '') +
          this.options.maxParameter + '=' + this.lastUpdated +
          '&'+ this.options.pageParameter + '=' + page;
    }.bind(this));
  },

  _setLastUpdated: function(ts) {
    this.lastUpdated = ts;
    this._updatePagination();
  },

  _consolidateUpdates: function(data) {
    var newTimestamp = data['timestamp'];
    var videos = $A(data['videos']);
    
    // update the videos on the page, oldest first, so that animations, if they happen slowly or are staggered, show a better-looking transition
    videos.reverse().each(function(videoData, index) {
      var hash = videoData['hash'];
      var html = videoData['html'];

      var existing = CastTV.TweetedVideo.get(hash);
      var tweetedVideo = existing;
      if (existing) {
        CastTV.TweetedVideo.replace(hash, html);
      } else {
        tweetedVideo = new CastTV.TweetedVideo(hash, { nodeHtml: html });
      }
      
      var callback = null;
      if (index === videos.length-1) {
        callback = function() {
          CastTV.TweetedVideo.nodes().slice(this.options.maxVideosPerPage).each(function(node) {
            CastTV.TweetedVideo.remove(CastTV.TweetedVideo.hashFromNode(node));
          }.bind(this));
          // in case we removed a video which was causing an 'auto-updates paused' warning...
          this.updateNotifications(CastTV.TweetedVideo.isEmbedVisible());
        }.bind(this);
      }
      tweetedVideo.show(callback);
    }.bind(this));

    this._setLastUpdated(newTimestamp);
  },

  _updateFetchUrl: function() {
    return this.options.updatesPath + this.lastUpdated + '.json' + (this.options.topic ? ('?'+this.options.topicParameter+'=' + encodeURIComponent(this.options.topic)) : '');
  },

  _tweetCountFetchUrl: function() {
    return this.options.tweetCountPath + this.lastUpdated + '.json' + (this.options.topic ? ('?'+this.options.topicParameter+'=' + encodeURIComponent(this.options.topic)) : '');
  },

  _fetchUpdates: function(force, callback) {
    var url = this._updateFetchUrl();
    this.updateSpinner.appear();
    CastTV.get(url, function(transport) {
      if (transport.status >= 200 && transport.status < 300) {
        this.delayedConsolidationData = null;
        var data = eval('('+transport.responseText+')');
        if (!force && this.autoUpdatesPaused) {
          this.delayedConsolidationData = data;
        } else {
          this.updateNotes.each(function(elt) { elt.hide(); });
          this._consolidateUpdates(data);
        }
        this.updateSpinner.fade();
        
        if (callback) {
          callback();
        }
      }
    }.bind(this));
  },

  _fetchNow: function(force) {
    this.restart();
    this._fetchUpdates(force);
    this.updateNotes.each(function(elt) { elt.hide(); });
  },

  _checkWhetherUpdatesFrozen: function(shouldTurnAutoOn) {
    if (this.options.updatesFrozen && this.options.currentPage > 1) {
      this.updateSpinner.appear();
      document.location = this.options.tweetHomePath + ((this.options.topic || shouldTurnAutoOn) ? '?' : '') +
          (this.options.topic ? (this.options.topicParameter+'=' + encodeURIComponent(this.options.topic)) + (shouldTurnAutoOn ? '&' : '') : '') +
          (shouldTurnAutoOn ? (this.options.autoUpdateParameter + '=1') : '');
      return true;
    } else if (this.options.updatesFrozen) {
      // we are on page 1. user doesn't want the page frozen any longer.
      this.options.updatesFrozen = false;
    }
    return false;
  },

  _registerNotifications: function() {
    this.warningNotes = $$(this.options.warningNotesSelector);
    this.updateNotes = $$(this.options.updateNotesSelector);
    this.updateCounts = $$(this.options.updateCountsSelector);
    this.updateRefreshLinks = $$(this.options.updateRefreshesSelector);
    this.updateSpinner = $$(this.options.updateSpinnerSelector)[0];
    this.topOfPage = $$(this.options.topOfPageSelector)[0];

    this.updateRefreshLinks.each(function(elt) {
      elt.observe('click', function(e) {
        e.stop();
        if (!this._checkWhetherUpdatesFrozen(false)) {
          this._fetchNow(true);
          if (elt != this.updateRefreshLinks[0]) {
            // CastTV.scrollIntoView($('header'));
            Effect.ScrollTo(this.topOfPage);
          }
        }
      }.bindAsEventListener(this));
    }.bind(this));
  },

  _registerNavigation: function() {
    if (this.options.updateTweetsOnly) {
      return;
    }

    var autoUpdateOnRadio = this.autoUpdateRadio = $$(this.options.autoUpdateRadioSelector)[0];
    var autoUpdateOffRadio = $$(this.options.autoUpdateRadioSelector)[1];

    autoUpdateOnRadio.observe('click', function(e) {
      if (!this._checkWhetherUpdatesFrozen(true)) {
        this.updateNotifications(CastTV.TweetedVideo.isEmbedVisible());
      }
    }.bindAsEventListener(this));

    autoUpdateOffRadio.observe('click', function(e) {
      this.warningNotes.each(function(elt) { elt.hide() });
    }.bindAsEventListener(this));
  },

  _notifyAboutUpdates: function(count) {
    this.updateCounts.each(function(elt) { elt.update(count) });
    (0 == CastTV.TweetedVideo.instances.size() ? [this.updateNotes[0]] : this.updateNotes).each(function(elt) { elt.appear({ duration: 1.0 }); });
  },

  _fetchTweetCount: function(callback) {
    var url = this._tweetCountFetchUrl();
    CastTV.get(url, function(transport) {
      if (transport.status >= 200 && transport.status < 300) {
        var count = parseInt(transport.responseText);
        if (count > 0) {
          this._notifyAboutUpdates(count);
        }
        
        if (callback) {
          callback();
        }
      }
    }.bind(this));
  },

  _autoFetchUpdates: function() {
    var radioButtons = $$(this.options.autoUpdateRadioSelector);

    if (0 == radioButtons.length) {
      return false;
    }

    var formValue = $F(radioButtons.find(function(re) {
      return re.checked;
    }));

    return this.options.autoUpdateRadioOnValue === formValue;
  },

  _startUpdater: function() {
    var updateTimestampsCallback = function() {
      CastTV.TweetedVideo.updateTimestamps();
    };

    this.updater = new PeriodicalExecuter(function(pe) {
      if (this.options.updateTweetsOnly) {
        CastTV.TweetedVideo.updateTimestamps();
      } else if (! this.updateSpinner.visible()) {
        // Until operations for a topic aren't expensive:
        if (! this.options.topic) {
          if (this._autoFetchUpdates()) {
            this._fetchUpdates(false, updateTimestampsCallback);
          } else {
            this._fetchTweetCount(updateTimestampsCallback);
          }
        }
      }
    }.bind(this), this.options.loopInterval);
  },

  start: function(timestamp) {
    if ('undefined' === typeof timestamp || null === timestamp) {
      if (!this.lastUpdated) {
        throw 'CastTV.TweetDaemon#start needs a timestamp if it hasn\'t before been run';
      }
    } else {
      if ('number' !== typeof timestamp) {
        throw 'CastTV.TweetDaemon#start requires a numerical timestamp';
      }
      this._setLastUpdated(timestamp);
    }
    // Until operations for a topic aren't expensive:
    if (! this.options.topic) {
      if (1 == this.currentPage && this._autoFetchUpdates()) {
        this._fetchUpdates();
      } else if (this.options.updatesFrozen) {
        this._fetchTweetCount();
      }
    }
    this._startUpdater();
  },

  updateNotifications: function(embedPlaying) {
    if (embedPlaying && this._autoFetchUpdates()) {
      this.autoUpdatesPaused = true;
      this.warningNotes.each(function(elt) { elt.show(); });
    } else {
      this.autoUpdatesPaused = false;
      this.warningNotes.each(function(elt) { elt.hide(); });

      if (this.delayedConsolidationData) {
        this._consolidateUpdates(this.delayedConsolidationData);
        this.delayedConsolidationData = null;
      }

      if (this.updateNotes[0].visible() && this._autoFetchUpdates()) {
        // we are changing from non-auto to auto mode.
        this._fetchNow();
      }
    }
  }
});

Object.extend(CastTV.TweetDaemon, {
  instance: null
});

CastTV.SearchBox = Class.create({
  initialize: function(container, options) {
    this.options = {
      textboxClass: 'player_search_text',
      buttonClass: 'player_search_button_icon',
      placeholderText: 'Search for Videos',
      searchTextColor: '#333',
      placeholderTextColor: '#999',
      trackingCode: null
    };
    Object.extend(this.options, options || {});
    this.container = $(container);

    this.textbox = this.container.select('.'+this.options.textboxClass)[0];
    this.button = this.container.select('.'+this.options.buttonClass)[0];

    this._attachEvents();
    this._setPlaceholderText();
  },

  _setPlaceholderText: function() {
    this.textbox.setStyle({ 'color': this.options.placeholderTextColor });
    this.textbox.value = this.options.placeholderText;
  },

  _attachEvents: function() {
    this.textbox.observe('keypress', function(e) {
      if (Event.KEY_RETURN === e.keyCode) {
        this.launch();
      } else if (Event.KEY_ESC === e.keyCode && '' === this.textbox.value) {
        this.textbox.blur(); // otherwise firefox insists on doing some of its own autocomplete magic
      }
    }.bindAsEventListener(this));

    this.textbox.observe('focus', function(e) {
      if (this.options.placeholderText === this.textbox.value) {
        this.textbox.value = '';
        this.textbox.setStyle({ 'color': this.options.searchTextColor });
      }
    }.bindAsEventListener(this));

    this.textbox.observe('blur', function(e) {
      if ('' === this.textbox.value) {
        this._setPlaceholderText();
      }
    }.bindAsEventListener(this));

    this.button.observe('click', function(e) {
      e.stop();
      this.launch();
    }.bindAsEventListener(this));
  },

  launch: function() {
    if (!this.textbox || this.options.placeholderText === this.textbox.value) {
      CastTV.openSearchPage('', this.options.trackingCode);
    } else {
      CastTV.openSearchPage(this.textbox.value, this.options.trackingCode);
    }
  }
});

CastTV.IFrame = Class.create({
  initialize: function(container, hash, options) {
    this.options = {
      navigation: false,
      prevUrl: null,
      nextUrl: null,
      navigationControlsClass: 'iframe_previous_next',
      leftNavControlEnabledClass: 'iframe_previous_bt',
      rightNavControlEnabledClass: 'iframe_next_bt',
      leftNavControlDisabledClass: 'iframe_previous_bt_disabled',
      rightNavControlDisabledClass: 'iframe_next_bt_disabled'
    };
    Object.extend(this.options, options || {})
    this.container = $(container);
    this.hash = hash;

    if (this.options.navigation) {
      this._registerNavigation();
    }
    this._registerSearchBox();
  },

  _registerNavigation: function() {
    this.navigationControls = this.container.select('.'+this.options.navigationControlsClass)[0];
    this.leftNavLink = this.navigationControls.select('.'+this.options.leftNavControlDisabledClass)[0];
    this.rightNavLink = this.navigationControls.select('.'+this.options.rightNavControlDisabledClass)[0];

    var no_op = function(e) { e.stop(); };
    if (this.options.prevUrl) {
      this.leftNavLink.href = this.options.prevUrl;
      this.leftNavLink.addClassName(this.options.leftNavControlEnabledClass);
      this.leftNavLink.removeClassName(this.options.leftNavControlDisabledClass);
    } else {
      this.leftNavLink.observe('click', no_op);
      this.leftNavLink.setStyle({'cursor': 'default'});
    }
    if (this.options.nextUrl) {
      this.rightNavLink.href = this.options.nextUrl;
      this.rightNavLink.addClassName(this.options.rightNavControlEnabledClass);
      this.rightNavLink.removeClassName(this.options.rightNavControlDisabledClass);
    } else {
      this.rightNavLink.observe('click', no_op);
      this.rightNavLink.setStyle({'cursor': 'default'});
    }

    this.navigationControls.show();
  },

  _registerSearchBox: function() {
    var sb = new CastTV.SearchBox(this.container, { textboxClass: 'iframe_search_text', buttonClass: 'iframe_search_button_icon' });
  }
});


/*
 *
 *  Ajax Autocomplete for Prototype, version 1.0.3
 *  (c) 2008 Tomas Kirda
 *
 *  Ajax Autocomplete for Prototype is freely distributable under the terms of an MIT-style license.
 *  For details, see the web site: http://www.devbridge.com/projects/autocomplete/
 *
 */

var Autocomplete = function(el, options){
  this.el = $(el);
  this.id = this.el.identify();
  this.el.setAttribute('autocomplete','off');
  this.suggestions = [];
  this.data = [];
  this.badQueries = [];
  this.selectedIndex = -1;
  this.currentValue = this.el.value;
  this.intervalId = 0;
  this.cachedResponse = [];
  this.instanceId = null;
  this.onChangeInterval = null;
  this.ignoreValueChange = false;
  this.serviceUrl = options.serviceUrl;
  this.options = {
    autoSubmit:false,
    minChars:1,
    maxHeight:300,
    deferRequestBy:10,
    width:0,
    container:null
  };
  if (options) { Object.extend(this.options, options); }
  this.initialize();
};

Autocomplete.instances = [];
Autocomplete.isDomLoaded = false;

Autocomplete.getInstance = function(id){
  var instances = Autocomplete.instances;
  var i = instances.length;
  while(i--){ if(instances[i].id === id){ return instances[i]; }}
};

Autocomplete.normalizeWords = function(words) {
  return words.map(function(word) {
    return word.replace(/\W/g, '')
  });
};

Autocomplete.highlight = function(value, re) {
  var words = value.match(/\S+/g);
  var normalizedWords = Autocomplete.normalizeWords(words); // .join(' ');

  var highlightStartIndex = Infinity;
  var highlightedString = '';
  words.each(function(word, index) {
    if (index > 0 && word.length > 0) {
      highlightedString += ' ';
    }

    var normalizedWord = normalizedWords[index];
    var wordMatch = normalizedWord.match(re);
    if (wordMatch) {
      var highlightedPrefix = word.match(/(\w\W*)/ig).splice(0, wordMatch[0].length);
      var leadingPunctuation = word.match(/^(\W*)\w/)[1];
      if (leadingPunctuation) {
        highlightedPrefix.unshift(leadingPunctuation);
      }
      // don't want to highlight any straggling punctuation (i.e., don't want to highlight "it'" if only "it" has been typed for the word "it's")
      if (highlightedPrefix.length > 0) {
        highlightedPrefix[highlightedPrefix.length-1] = highlightedPrefix[highlightedPrefix.length-1].replace(/\W*$/, '');
      }
      highlightedPrefix = highlightedPrefix.join('');
      var rest = word.split('').splice(highlightedPrefix.length, word.length).join('');
      if (rest.match(/^\W+$/)) {
        highlightedPrefix += rest;
        rest = '';
      }

      if (Infinity == highlightStartIndex) {
        highlightStartIndex = highlightedString.length;
      }
      highlightedString += '<strong>' + highlightedPrefix + '</strong>' + rest;
    } else {
      highlightedString += word;
    }
  });

  return [highlightedString, highlightStartIndex];
};

RegExp.escape = (function() {
  var specials = ['.', '*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '\\'];
  sRE = new RegExp('(\\' + specials.join('|\\') + ')', 'g');
  return function(text) {
    return text.replace(sRE, '\\$1');
  }
})();

Autocomplete.prototype = {

  killerFn: null,

  initialize: function() {
    var me = this;
    this.killerFn = function(e) {
      if (!$(Event.element(e)).up('.autocomplete')) {
        me.killSuggestions();
        me.disableKillerFn();
      }
    } .bindAsEventListener(this);

    if (!this.options.width) { this.options.width = this.el.getWidth(); }

    var div = new Element('div', { style: 'position:absolute; z-index:995;' });
    div.update('<div class="autocomplete-w1"><div class="autocomplete-w2"><div class="autocomplete" id="Autocomplete_' + this.id + '" style="display:none; width:' + this.options.width + 'px;"></div></div></div>');

    this.options.container = $(this.options.container);
    if (this.options.container) {
      this.options.container.appendChild(div);
      this.fixPosition = function() { };
    } else {
      document.body.appendChild(div);
    }

    this.mainContainerId = div.identify();
    this.container = $('Autocomplete_' + this.id);
    this.fixPosition();
    
    Event.observe(this.el, window.opera ? 'keypress':'keydown', this.onKeyPress.bind(this));
    Event.observe(this.el, 'keyup', this.onKeyUp.bind(this));
    Event.observe(this.el, 'blur', this.enableKillerFn.bind(this));
    Event.observe(this.el, 'focus', this.fixPosition.bind(this));
    this.container.setStyle({ maxHeight: this.options.maxHeight + 'px' });
    this.instanceId = Autocomplete.instances.push(this) - 1;
  },

  fixPosition: function() {
    var offset = this.el.cumulativeOffset();
    $(this.mainContainerId).setStyle({ top: (offset.top + this.el.getHeight()) + 'px', left: offset.left + 'px' });
  },

  enableKillerFn: function() {
    Event.observe(document.body, 'click', this.killerFn);
  },

  disableKillerFn: function() {
    Event.stopObserving(document.body, 'click', this.killerFn);
  },

  killSuggestions: function() {
    this.stopKillSuggestions();
    this.intervalId = window.setInterval(function() { this.hide(); this.stopKillSuggestions(); } .bind(this), 300);
  },

  stopKillSuggestions: function() {
    window.clearInterval(this.intervalId);
  },

  onKeyPress: function(e) {
    if (!this.enabled) { return; }
    // return will exit the function
    // and event will not fire
    switch (e.keyCode) {
      case Event.KEY_ESC:
        this.el.value = this.currentValue;
        this.hide();
        break;
      case Event.KEY_TAB:
      case Event.KEY_RETURN:
        if (this.selectedIndex === -1) {
          this.hide();
          return;
        }
        this.select(this.selectedIndex);
        if (e.keyCode === Event.KEY_TAB) { return; }
        break;
      case Event.KEY_UP:
        this.moveUp();
        break;
      case Event.KEY_DOWN:
        this.moveDown();
        break;
      default:
        return;
    }
    Event.stop(e);
  },

  onKeyUp: function(e) {
    switch (e.keyCode) {
      case Event.KEY_UP:
      case Event.KEY_DOWN:
        return;
    }
    clearInterval(this.onChangeInterval);
    if (this.currentValue !== this.el.value) {
      if (this.options.deferRequestBy > 0) {
        // Defer lookup in case when value changes very quickly:
        this.onChangeInterval = setInterval((function() {
          this.onValueChange();
        }).bind(this), this.options.deferRequestBy);
      } else {
        this.onValueChange();
      }
    }
  },

  onValueChange: function() {
    clearInterval(this.onChangeInterval);
    this.currentValue = this.el.value;
    this.selectedIndex = -1;
    if (this.ignoreValueChange) {
      this.ignoreValueChange = false;
      return;
    }
    if (this.currentValue === '' || this.currentValue.length < this.options.minChars) {
      this.hide();
    } else {
      this.getSuggestions();
    }
  },

  getSuggestions: function() {
    var cr = this.cachedResponse[this.currentValue];
    if (cr && Object.isArray(cr.suggestions)) {
      this.suggestions = cr.suggestions;
      this.data = cr.data;
      this.suggest();
    } else if (!this.isBadQuery(this.currentValue)) {
      new Ajax.Request(this.serviceUrl + '/' + encodeURIComponent(this.currentValue), {
        // parameters: { query: this.currentValue },
        onComplete: this.processResponse.bind(this),
        method: 'get'
      });
    }
  },

  isBadQuery: function(q) {
    var i = this.badQueries.length;
    while (i--) {
      if (q.indexOf(this.badQueries[i]) === 0) { return true; }
    }
    return false;
  },

  hide: function() {
    this.enabled = false;
    this.selectedIndex = -1;
    this.container.hide();
  },

  suggest: function() {
    if (this.suggestions.length === 0) {
      this.hide();
      return;
    }
    var unsortedContent = $A([]);
    var normalizedTypedWords = Autocomplete.normalizeWords(this.currentValue.match(/\S+/g));
    var normalizedRe = new RegExp('\\b' + normalizedTypedWords.map(function(word){ return RegExp.escape(word); }).join('|\\b'), 'gi');
    this.suggestions.each(function(value, i) {
      var suggestionType = this.data[i]['type'];
      var highlightedRetval = Autocomplete.highlight(value, normalizedRe);
      var highlightedValue = highlightedRetval[0];
      var highlightedIndex = highlightedRetval[1];
      var htmlArrayFn = function(activationIndex) {
        return [(this.selectedIndex === i ? '<div class="selected"' : '<div'), ' title="', value, '" onclick="Autocomplete.instances[', this.instanceId,
                '].select(', activationIndex, ');" onmouseover="Autocomplete.instances[', this.instanceId, '].activate(', activationIndex, ');">', highlightedValue,
                ' <span class="parenthetical">(' + suggestionType + ')</span></div>'];
      }.bind(this);
      // each of the items in unsorted content will be sorted by highlightedIndex, then i, which is the index in which the items were returned from the webapp
      unsortedContent.push([htmlArrayFn, highlightedIndex, i]);
    } .bind(this));

    var newSuggestions = [];
    var newData = [];

    var sortedIndex = 0;
    var content = unsortedContent.sort(function(a, b) {
      var aHighlightIndex = a[1];
      var aDefaultPosition = a[2];
      var bHighlightIndex = b[1];
      var bDefaultPosition = b[2];

      if (aHighlightIndex != bHighlightIndex) {
        return (aHighlightIndex < bHighlightIndex ? -1 : 1);
      }
      return (aDefaultPosition < bDefaultPosition ? -1 : 1);
    }).map(function(x) {
      var position = x[2];
      newSuggestions.push(this.suggestions[position]);
      newData.push(this.data[position]);
      var htmlArray = x[0](sortedIndex);
      sortedIndex += 1;
      return htmlArray;
    }.bind(this)).flatten();

    this.container.update(content.join('')).show();
    this.suggestions = newSuggestions;
    this.data = newData;

    this.enabled = true;
  },

  processResponse: function(xhr) {
    var response;
    try {
      response = xhr.responseText.evalJSON();
      if (!Object.isArray(response.data)) { response.data = []; }
    } catch (err) { return; }
    if (response.suggestions.length === 0) { this.badQueries.push(response.query); }
    this.cachedResponse[response.query] = response;

    if (response.query === this.currentValue) {
      this.suggestions = response.suggestions;
      this.data = response.data;
      this.suggest();
    }
  },

  activate: function(index) {
    var divs = this.container.childNodes;
    var activeItem;
    // Clear previous selection:
    if (this.selectedIndex !== -1 && divs.length > this.selectedIndex) {
      divs[this.selectedIndex].className = '';
    }
    this.selectedIndex = index;
    if (this.selectedIndex !== -1 && divs.length > this.selectedIndex) {
      activeItem = divs[this.selectedIndex]
      activeItem.className = 'selected';
    }
    return activeItem;
  },

  deactivate: function(div, index) {
    div.className = '';
    if (this.selectedIndex === index) { this.selectedIndex = -1; }
  },

  select: function(i) {
    var selectedValue = this.suggestions[i];
    if (selectedValue) {
      this.el.value = selectedValue;
      if (this.options.autoSubmit && this.el.form) {
        this.el.form.submit();
      }
      this.ignoreValueChange = true;
      this.hide();
      this.onSelect(i);
    }
  },

  moveUp: function() {
    if (this.selectedIndex === -1) { return; }
    if (this.selectedIndex === 0) {
      this.container.childNodes[0].className = '';
      this.selectedIndex = -1;
      this.el.value = this.currentValue;
      return;
    }
    this.adjustScroll(this.selectedIndex - 1);
  },

  moveDown: function() {
    if (this.selectedIndex === (this.suggestions.length - 1)) { return; }
    this.adjustScroll(this.selectedIndex + 1);
  },

  adjustScroll: function(i) {
    this.el.value = this.suggestions[i];
    var container = this.container;
    var activeItem = this.activate(i);
    var offsetTop = activeItem.offsetTop;
    var upperBound = container.scrollTop;
    var lowerBound = upperBound + this.options.maxHeight - 25;
    if (offsetTop < upperBound) {
      container.scrollTop = offsetTop;
    } else if (offsetTop > lowerBound) {
      container.scrollTop = offsetTop - this.options.maxHeight + 25;
    }
  },

  onSelect: function(i) {
    (this.options.onSelect || Prototype.emptyFunction)(this.suggestions[i], this.data[i]);
  }

};

CastTV.ValidatedText = Class.create({
  initialize: function(container, options) {
    this.options = {
      textNodeSelector: 'input[type=text],input[type=password]',
      descNodeSelector: '.parenthetical',
      timerDuration: 800,
      styles: $A(['error', 'success', 'waiting']),
      minimumLength: 0,
      maximumLength: Infinity,
      defaultDescription: ''
    };
    Object.extend(this.options, options || {});

    this._container = container;
    this._textNode = container.select(this.options.textNodeSelector)[0];
    this._descNode = container.select(this.options.descNodeSelector)[0];
    this._lastValue = null;
    this._timer = null;
    this._postValidationHooks = $A([]);

    this._textNode.observe('keyup', function(e) {
      this._restartTimer();
    }.bindAsEventListener(this));

    var initialValue = this._textNode.value;
    if (undefined !== initialValue) {
      this._lastValue = initialValue;
      if (this._markedErroneous()) {
        this._focusForErrorCorrection();
      } else if (initialValue.length > 0) {
        this.validate(initialValue);
      }
    }

    this._textNode.observe('focus', function(e) {
      if (this._markedErroneous() && this._textNode.value.length === 0) {
        this.info(this.options.defaultDescription);
      }
    }.bindAsEventListener(this));

    CastTV.ValidatedText.instances.set(this._textNode.id.match(/user_(\w+)$/)[1], this);
  },

  info: function(str) {
    // possibly show an image here
    return this._description(str);
  },

  error: function(str) {
    // possibly show an image here
    return this._description(str).addClassName('error');
  },

  success: function(str) {
    // possibly show an image here
    return this._description(str).addClassName('success');
  },

  waiting: function(str) {
    // possibly show an image here
    return this._description(str).addClassName('waiting');
  },

  validate: function(value) {
    if (value.length === 0) {
      this.info(this.options.defaultDescription);
    } else if (value.length < this.options.minimumLength) {
      this.error('must be at least ' + this.options.minimumLength + ' characters long');
    } else if (value.length > this.options.maximumLength) {
      this.error('must be at most ' + this.options.maximumLength + ' characters long');
    } else {
      return true;
    }
    return false;
  },

  addPostValidationHook: function(callback) {
    this._postValidationHooks.push(callback);
  },

  _description: function(str) {
    this.options.styles.each(function(className) {
      this._container.removeClassName(className);
    }.bind(this));
    this._descNode.update(str);
    return this._container;
  },

  _restartTimer: function() {
    clearTimeout(this._timer);
    this._timer = setTimeout(function() {
      var value = this._textNode.value;
      if (this._lastValue !== value) {
        this.validate(value);
        this._postValidationHooks.each(function(callback) { callback(); });
        this._lastValue = value;
      }
    }.bind(this), this.options.timerDuration);
  },

  _markedErroneous: function() {
    return this._container.hasClassName('error');
  },

  _focusForErrorCorrection: function() {
    this._textNode.focus();
    this._textNode.select();
  }
});

Object.extend(CastTV.ValidatedText, {
  instances: new Hash(),
  get: function(field) {
    return CastTV.ValidatedText.instances.get(field);
  }
});

CastTV.ValidatedUsername = Class.create(CastTV.ValidatedText, {
  initialize: function($super, container, options) {
    var usernameOptions = {
      loginRegex: /^[a-z0-9\_\-]+$/i,
      minimumLength: 4,
      maximumLength: 20
    };
    Object.extend(usernameOptions, options || {});
    $super(container, usernameOptions);
  },

  validate: function($super, login) {
    if (!$super(login)) return false;

    if (this.options.loginRegex.test(login)) {
      this.waiting('checking availability...');

      CastTV.get('/users/available.json?username=' + encodeURIComponent(login), function(transport) {
        if (transport.status >= 200 && transport.status < 300) {
          if (login === this._textNode.value) {
            var data = eval('('+transport.responseText+')');
            if (data.available) {
              this.success('available');
            } else {
              this.error('username is unavailable');
            }
          }
        }
      }.bind(this));
    } else {
      this.error('must only contain letters, numbers, or <tt>-_</tt>');
    }
  }
});

CastTV.ValidatedPassword = Class.create(CastTV.ValidatedText, {
  initialize: function($super, container, options) {
    var passwordOptions = {
      styles: $A(['error', 'success', 'waiting', 'weak', 'medium', 'strong']),
      // adapted from http://stackoverflow.com/questions/198974/asp-net-regular-expression-validator-password-strength
      strongPasswordRegex: /^(?=.{8,}$)(?=(?:.*?\d){2})(?=(?:.*?[A-Za-z]){2})(?=(?:.*?\W){1})/, // 8 length minimum, 2 numeric, 2 alpha, 1 non-alphanumeric
      mediumPasswordRegex: /^(?=.{8,}$)(?=(?:.*?\d){2})(?=(?:.*?[A-Za-z]){2})/, // don't require 1 non-alphanumeric char
      minimumLength: 4,
      maximumLength: 40
    };
    Object.extend(passwordOptions, options || {});
    $super(container, passwordOptions);

    var passwordConfValidator = CastTV.ValidatedText.get('password_confirmation');
    if (passwordConfValidator) {
      this.addPostValidationHook(function() {
        passwordConfValidator.validate(passwordConfValidator._textNode.value);
      });
    }
  },

  rate: function(password) {
    var rating = null;
    if (this.options.strongPasswordRegex.test(password)) {
      rating = 'strong';
    } else if (this.options.mediumPasswordRegex.test(password)) {
      rating = 'medium';
    } else {
      rating = 'weak';
    }
    return this.success('strength: ' + rating).addClassName(rating);
  },

  validate: function($super, password) {
    $super(password) && this.rate(password);
  }
});

CastTV.ValidatedPasswordConfirmation = Class.create(CastTV.ValidatedText, {
  initialize: function($super, container, options) {
    var passwordConfirmationOptions = {
      styles: $A(['error', 'success', 'waiting', 'weak', 'medium', 'strong'])
    };
    Object.extend(passwordConfirmationOptions, options || {});

    // Hack.  we need originalPasswordTextNode to be set before we call our super constructor
    //   This requires us to set _textNode ourselves first though
    this._textNode = container.select('input[type=password]')[0];
    this._originalPasswordTextNode = $(this._textNode.id.replace(/_confirmation$/, ''));

    $super(container, passwordConfirmationOptions);
  },

  validate: function(confirmedPassword) {
    if (confirmedPassword.length === 0) {
      this.info(this.options.defaultDescription);
    } else if (confirmedPassword === this._originalPasswordTextNode.value) {
      this.success('passwords match');
    } else {
      this.error('passwords do not match');
    }
  },

  _focusForErrorCorrection: function() {
    this._originalPasswordTextNode.focus();
    this._originalPasswordTextNode.select();
  }
});

CastTV.ValidatedEmail = Class.create(CastTV.ValidatedText, {
  initialize: function($super, container, options) {
    var emailOptions = {
      maximumLength: 100
    };
    Object.extend(emailOptions, options || {});
    $super(container, emailOptions);
  },

  validate: function($super, email) {
    if (!$super(email)) return false;

    if (CastTV.validateEmail(email)) {
      this.waiting('validating email address...');

      CastTV.get('/users/available.json?email=' + encodeURIComponent(email), function(transport) {
        if (transport.status >= 200 && transport.status < 300) {
          if (email === this._textNode.value) {
            var data = eval('('+transport.responseText+')');
            if (data.available) {
              this.success(this.options.defaultDescription);
            } else {
              this.error('email is already being used');
            }
          }
        }
      }.bind(this));
    } else {
      this.error('should be a valid email address');
    }
  }
});
