/**
 * Requires prototype.js
 */

/**
 * Namespace for Travelbug JavaScripts
 */
var TRAVELBUG = {};

/**
 * Prototypal methods
 */
// from http://www.svendtofte.com/code/usefull_prototypes/prototypes.js
Array.prototype.compareArrays = function (arr) {
    if (this.length !== arr.length) {
        return false;
    }
    for (var i = 0; i < arr.length; i++) {
        if (this[i].compareArrays) { //likely nested array
            if (!this[i].compareArrays(arr[i])) {
                return false;
            } else {
                continue;
            }
        }
        if (this[i] !== arr[i]) {
            return false;
        }
    }
    return true;
};

Number.prototype.mod = function (base) {
    var num;
    num = this % base;
    return num < 0 ? num + base : num;
};

Date.prototype.addDays = function (numDays) {
    return this.setDate(this.getDate() + numDays);
};

// this ain't the greatest idea, but for some reason Prototype's version doesn't like our JSON
// TODO add stripping of potentially "dangerous" strings
/*
This test from json.org may do the trick:

if (/^[\],:{}\s]*$/.test(text.replace(/\\["\\\/bfnrtu]/g, '@').
replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']').
replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) {

*/
String.prototype.jsonify = function () {
    try {
        var json;
        json = eval('(' + this + ')');
        return json;
    } catch (e) {
        return false;
    }
};

/**
 * Constants used across scripts accessible within the TRAVELBUG namespace
 */
TRAVELBUG.constants = {
    // event handlers
    CLICK       : 'click',
    MOUSEUP     : 'mouseup',
    MOUSEOUT    : 'mouseout',
    MOUSEOVER   : 'mouseover',
    MOUSEDOWN   : 'mousedown',
    MOUSEMOVE   : 'mousemove',
    DRAGSTART   : 'dragstart',
    FOCUS        : 'focus',
    // HTML tags
    DIV         : 'DIV',
    INPUT       : 'INPUT',
    LABEL       : 'LABEL',
    SELECT      : 'SELECT',
    OPTION      : 'OPTION',
    // misc
    HI          : '_hi.',
    PX          : 'px',
    LEFT        : 'left',
    NEXT        : 'next',
    PREVIOUS    : 'previous',
    DASH        : '-',
    SLASH       : '/',
    IN          : 'in',
    OUT         : 'out',
    // MAPS
    MINI        : 'mini',
    SEARCH      : 'search',
    MODAL       : 'modal',
    // guest details
    SELF        : 'self',
    OTHER       : 'other'
};

/**
 * UI fixes
 */
TRAVELBUG.fixes = (function () {
    // public methods
    return {
        homepage: {
            /**
             * Fix a display issue on the homepage in Safari 2 where the body content overlaps the top nav
             */
            contentTopMargin: function () {
                if (TRAVELBUG.guiUtils.isSafari2) {
                    $('content').setStyle({'margin-top': 0});
                }
            }
        }
    };
})();

/**
 * Common form utility functions
 */
TRAVELBUG.formUtils = (function () {
    // private vars
    var _tmpls;
    _tmpls = {
        childSelector   : new Template('##{id} #{type}')
    };
    // private methods

    // public methods
    return {
        /**
         * Take a form, preserving any query string on the action attribute, and make it a GET request
         */
        submitAsGet: function (formObj) {
            var action, fieldValues, separator, destination;
            action        = formObj.action;
            fieldValues   = formObj.serialize();
            fieldValues   = fieldValues.replace(/%20/g, '+');
            separator     = (action.indexOf('?') > -1)? '&': '?';
            destination   = action + separator + fieldValues;
            location.href = destination;
        },
        /**
         * Take the id's of two fieldset's and check whether child form elements have focus
         * @param object fieldsetIds     Object literal with values for primary and secondary fieldset IDs
         * @param string updateElementId Name / ID of the element to update
         */
        loginRegisterInit: function (fieldsetIds, updateElementId) {
            var updateElement, primaryElements, secondaryElements;
            updateElement     = $(updateElementId);
            primaryElements   = $$(_tmpls.childSelector.evaluate({id: fieldsetIds.primary, type: TRAVELBUG.constants.INPUT}));
            primaryElements.push($$(_tmpls.childSelector.evaluate({id: fieldsetIds.primary, type: TRAVELBUG.constants.SELECT})));
            secondaryElements = $$(_tmpls.childSelector.evaluate({id: fieldsetIds.secondary, type: TRAVELBUG.constants.INPUT}));
            secondaryElements.push($$(_tmpls.childSelector.evaluate({id: fieldsetIds.secondary, type: TRAVELBUG.constants.SELECT})));
            primaryElements.flatten().each(function (inputElement) {
                inputElement.observe(TRAVELBUG.constants.FOCUS, function () {
                    updateElement.value = fieldsetIds.primary;
                });
            });
            secondaryElements.flatten().each(function (inputElement) {
                inputElement.observe(TRAVELBUG.constants.FOCUS, function () {
                    updateElement.value = fieldsetIds.secondary;
                });
            });
        },
        /**
        * Take the id of an element, select all the child elements by type of your choice & add observe
        * methods of your choice to these
        * @param string elementId        Name/ID of Parent element
        * @param string childType        What child elements of "type" to select
        * @param string observeAction    What observe action to append to the child elements, eg click, blur
        * @param object action            The function to be appended & fired to child element on observeAction
        */
        addObserve: function(elementId, childType, observeAction, action) {
            var childElements;
            if("undefined" === typeof action) {
                throw ('PEBCAK: Variable "action" is undefined');
                return;
            }
            childElements = $$(_tmpls.childSelector.evaluate({id: elementId, type: childType}));
            childElements.each(function(childElement){
                childElement.observe(observeAction, action);
            });
        },
        /**
         * Add an option to an existing select tag
         * Cribbed from http://stevenharman.net/blog/archive/2007/07/10/add-option-elements-to-a-select-list-with-javascript.aspx
         *
         * @param object selectObj	The select object to append to
         * @param string text		Text to display in select option
         * @param string value		Select option value
         * @param bool	 isSelected Should this current index/option be selected
         */
        addSelectOption: function(selectObj, text, value, isSelected) {
            if (selectObj != null && selectObj.options != null)
            {
                selectObj.options[selectObj.options.length] = new Option(text, value, false, isSelected);
            }
        },
        /**
         * Toggle enabled/disabled for one or more containers (e.g., a fieldset) and all of its elements
         * @param array containerIds Array of DOM IDs of the container elements
         * @param array excludeIds   Array of DOM IDs to exclude from toggling
         */
        toggleFieldset: function (containerIds, excludeIds) {
            containerIds = containerIds || [];
            excludeIds   = excludeIds || [];
            $A(containerIds).each(function (containerId) {
                var container, inputs;
                container = $(containerId);
                inputs    = $A([
                    $A(container.getElementsByTagName('input')),
                    $A(container.getElementsByTagName('select')),
                    $A(container.getElementsByTagName('textarea'))
                ]).flatten();
                container.className = container.className.strip(); // trim any errant whitespace
                // currently disabled (do the less expensive check first)
                if ('disabled' === container.className || container.className.indexOf('-disabled') > -1) {
                    if ('disabled' === container.className) {
                        container.className = '';
                    } else {
                        container.className = container.className.replace(/-disabled/, '');
                    }
                    $A(inputs).each(function (input) {
                        if (excludeIds.indexOf(input.id) < 0) {
                            input.disabled = false;
                        }
                    });
                // currently enabled
                } else {
                    if ('' === container.className) {
                        container.className = 'disabled';
                    } else {
                        container.className = container.className + '-disabled';
                    }
                    $A(inputs).each(function (input) {
                        if (excludeIds.indexOf(input.id) < 0) {
                            input.disabled = true;
                        }
                    });
                }
            });
        }
    };
})();

/**
* Common GUI functions (should load first because other functions rely on TRAVELBUG.guiUtils.isIE)
*/
TRAVELBUG.guiUtils = (function () {
    // private methods
    var _getExtension;

    /**
    * Return the extension from a filename
    *
    * @param string filename
    * @return string
    */
    _getExtension = function (filename) {
        var pieces;
        pieces = filename.split('.');
        return pieces[pieces.length - 1];
    };

    // public variables and methods
    return {
        /**
         * Determine whether the browser is Internet Explorer
         */
        isIE: (document.all && !window.opera),

        /**
         * Determine whether the browser is that vile creation, Internet Explorer 6
         */
        isIE6: (document.all && !window.opera && navigator.appVersion.indexOf('MSIE 6') > -1),

        /**
         * Determine whether the browser is Safari 2 or below
         */
        isSafari2: (function () {
            var isSafari2, pieces;
            isSafari2 = false;
            if (navigator.userAgent.indexOf('AppleWebKit') > -1) {
                pieces = navigator.appVersion.split('/');
                if (1 * pieces[pieces.length - 1] < 500) {
                    isSafari2 = true;
                }
            }
            return isSafari2;
        })(),

        /**
        * Pop-up a new window in a standard way and set focus on the new window
        * Usage: <a href="myurl.html" onclick="TRAVELBUG.guiUtils.popup(this.href, 'myWindow');return false;">
        */
        popup: function (url, windowName, popupParams) {
            var newWindow;
            popupParams = popupParams || 'location=no,toolbar=no,menubar=no,resizeable=no,width=600,height=500,left=100,top=0';
            newWindow = window.open(url, windowName, popupParams);
            if (window.focus) {
                newWindow.focus();
            }
            return false;
        },
        /**
        * Add an option to a select list
        *
        * @param object selObj DOM object of the select list we want to update
        * @param string value  Value to which to set the option
        * @param string text   Visible label for the option tag
        */
        addOption: function (selObj, value, text) {
            var option;
            option = document.createElement(TRAVELBUG.constants.OPTION);
            selObj.appendChild(option);
            option.value = value;
            option.text = text;
        },
        /**
        * Perform image rollovers and other image stuff
        *
        * @param object img Image element on which we are to perform the action
        */
        images: {
            /**
             * Preload images
             * @param array imgSrcs Array of image sources to preload
             */
            preload: function (imgSrcs) {
                var img;
                img = new Image();
                imgSrcs = imgSrcs || [];
                $A(imgSrcs).each(function (src) {
                    img.src = src;
                });
            },
            hi: function (img) {
                var extension;
                extension = _getExtension(img.src);
                if (img.src.indexOf(TRAVELBUG.constants.HI + extension) < 0) {
                    img.src = img.src.replace('.' + extension, TRAVELBUG.constants.HI + extension);
                }
            },
            lo: function (img) {
                var extension;
                extension = _getExtension(img.src);
                if (img.src.indexOf(TRAVELBUG.constants.HI + extension) > 0) {
                    img.src = img.src.replace(TRAVELBUG.constants.HI + extension, '.' + extension);
                }
            }
        },
        /**
         * Return the hash value from the URL or false if there is none
         */
        getHash: function () {
            return ('' !== window.location.hash && '#' !== window.location.hash)? window.location.hash.slice(1): false;
        }
    };
})();

/**
 * Add behaviours to Step 1 of the booking process
 */
TRAVELBUG.bookingStep1 = (function () {
    return {
        showProcessingImage: function () {
            $('btn_confirm_and_pay').src = '/images/btn_processing.gif';
            TRAVELBUG.bookingDetails.persistSpecialRequests(function () {
                setTimeout(function () {
                    document.forms.formStep1.submit();
                }, 500);
            });
        }
    };
})();

/**
 * Add behaviours to booking details section of Step 1
 */
TRAVELBUG.bookingDetails = (function() {
    return {
        reconcileSelects: function(id, capacity, allowchildren, promo) {
            var parts, idx, num_adults, sel_children, val_children, num_children;
            parts = id.split('_');
            idx = parts[parts.length - 1];
            num_adults = $F('adult_select_' + idx);
            if (allowchildren) {
                sel_children = $('child_select_' + idx);
                val_children = $F('child_select_' + idx);
                num_children = capacity - num_adults;
                if (val_children > num_children) val_children = num_children;
                if (allowchildren == 0) num_children = 0;
                sel_children.options.length = 0;
                for (var i = 0, len = num_children + 1; i < len; ++i) {
                    sel_children.options[sel_children.options.length] = new Option(i, i);
                }
                sel_children.selectedIndex = val_children;
            }
            if (promo) {
                this.updatePromoTotals(idx, allowchildren);
            }
            else {
                this.updateTotals(idx, allowchildren);
            }
        },
        updatePromoTotals: function(idx, allowchildren) {
            var url, options, request;
            url = '/secure/book/update/guests';
            options = {
                method: 'post',
                parameters: {
                    rowNumber: idx,
                    children: allowchildren ? $F('child_select_' + idx) : "0",
                    adults: $F('adult_select_' + idx),
                    guid: $F('Guid')
                },
                onFailure: function() {
                    window.location.href = '/book/error';
                }
            }

            request = new Ajax.Request(url, options);
        },
        updateTotals: function(idx, allowchildren) {
            var url, options, request;
            url = '/secure/book/update/guests';
            options = {
                method: 'post',
                parameters: {
                    rowNumber: idx,
                    children: allowchildren ? $F('child_select_' + idx) : "0",
                    adults: $F('adult_select_' + idx),
                    guid: $F('Guid')
                },
                onSuccess: function(transport) {
                    var url, options, request;
                    $('product_price_summary_' + idx).innerHTML = transport.responseText;
                    var cacheBuster = parseInt(Math.random() * 99999999);
                    url = '/secure/book/total?bust=' + cacheBuster;
                    options = {
                        method: 'get',
                        parameters: {
                            guid: $F('Guid')
                        },
                        onSuccess: function(transporter) {
                            $('booking_total').innerHTML = 'Booking Total ' + transporter.responseText;
                            $('total-to-pay-value').innerHTML = transporter.responseText + '<span> (incl. GST)</span>';
                        }
                    }
                    request = new Ajax.Request(url, options);
                },
                onFailure: function() {
                    window.location.href = '/book/error';
                }
            }

            request = new Ajax.Request(url, options);
        },
        /**
        * Save the user's special requests before either redirecting to an URL or executing a callback function
        * @param string | function callback Either a string representing an URL to be followed or an executable
        *                                   function to be called when the Ajax request is complete
        */
        persistSpecialRequests: function(callback) {
            var timer, url, options, request, guid;
            timer = setTimeout(function() {
                $('ajax-indicator-add-room').setStyle({ display: 'inline' });
            }, 2000);
            //guid = $F('Guid');
            url = '/secure/book/persist/special';
            options = {
                parameters: {
                //  SpecialRequests: $F('SpecialRequests'),
                //     guid: guid
            },
            onSuccess: function() {
                clearTimeout(timer);
                if ('function' === typeof callback) {
                    callback();
                } else if ('string' === typeof callback) {
                    location.href = callback;
                }
            },
            onFailure: function() {
                window.location.href = '/secure/book/timeout';
            }
        };
        request = new Ajax.Request(url, options);
    }
};
})();

/**
 * Methods common to both the availability and deals calendars
 */
TRAVELBUG.calendarUtils = (function () {
    // public methods
    return {
        /**
         * Adjust the heights of calendar product/location and cell DIVs (assumes TRAVELBUG.calendarUtils.cache.data is hydrated)
         *
         * @param object calendar Namespaced calendar object (e.g., TRAVELBUG.dealsCalendar and TRAVELBUG.availabilityCalendar)
         */
        fixHeights: function (calendar) {
            var productObj, cellsObj;
            calendar.productIds.each(function (productId) {
                var height, marginTop, children, content, parent;

                productObj = calendar.getProductElement(productId);
                cellsObj   = calendar.getCellsElement(productId);

                // fix cell heights
                height = (TRAVELBUG.guiUtils.isIE)? productObj.clientHeight + TRAVELBUG.constants.PX: productObj.getStyle('height');
                marginTop = (Math.round(parseInt(height, 10) / 2) - 11) + TRAVELBUG.constants.PX;

                children = cellsObj.immediateDescendants();
                children.each(function (child) {
                    child.setStyle({height: height});
                    content = child.down();
                    if (content) {
                        content.setStyle({marginTop: marginTop});
                    }
                });

                parent = cellsObj.up();
                parent.setStyle({height: height});
            });
        },

        /**
         * Fix a z-index issue with Safari 2
         * Note: Elements are given incrementing z-index values, so you can stack things by specifying the order
         *       of the elements in the passed-in array
         *
         * @param array elements Array of DOM elements to be acted upon
         */
        fixSafari: function (elements) {
            var zIndex;
            zIndex = 9000;
            if (TRAVELBUG.guiUtils.isSafari2) {
                elements.each(function (element) {
                    element.setStyle({'z-index': zIndex});
                    ++zIndex;
                });
            }
        },

        /**
         * Return a range of visible dates, starting with the startDate through startDate + numDays
         *
         * @param string startDate Start date for the range, in YYYY-MM-DD format
         * @param int    numDays   Number of days visible to the user
         *
         * @return array Array of YYYY-MM-DD formatted date strings
         */
        calculateDateRange: function (startDate, numDays) {
            var dateObj, dateRange;
            dateObj   = TRAVELBUG.dates.ymd2obj(startDate);
            dateRange = $A([startDate]);
            (numDays - 1).times(function (n) {
                dateObj.addDays(1);
                dateRange[dateRange.length] = TRAVELBUG.dates.obj2ymd(dateObj);
            });
            return dateRange;
        },

 /**
        * Script created by Mark "Tarquin" Wilton-Jones.
        * http://www.howtocreate.co.uk/tutorials/javascript/browserwindow
        *
        * Calculates the x & y values of document after scroll.
        */

        getScrollXY: function () {
          var scrOfX = 0, scrOfY = 0;
          if( typeof( window.pageYOffset ) == 'number' ) {
            //Netscape compliant
            scrOfY = window.pageYOffset;
            scrOfX = window.pageXOffset;
          } else if( document.body && ( document.body.scrollLeft || document.body.scrollTop ) ) {
            //DOM compliant
            scrOfY = document.body.scrollTop;
            scrOfX = document.body.scrollLeft;
          } else if( document.documentElement && ( document.documentElement.scrollLeft || document.documentElement.scrollTop ) ) {
            //IE6 standards compliant mode
            scrOfY = document.documentElement.scrollTop;
            scrOfX = document.documentElement.scrollLeft;
          }
          return { x:scrOfX, y:scrOfY };
        }

        /**
         * Implement behaviour where the date row is pinned to the top of the page when the user scrolls
         *
         * @param string header  DOM ID of the calendar header container
         * @param string content DOM ID of the calendar body container
         */
        /**
         *
         * This function was committed by Kyle under issue #3121. However, TradeMe's intention
         * was that this was to be a prototype only. It was not meant to be released to the live
         * environment. This code has been commented out rather than deleted because issue #3121
         * is still queued and will therefore need to be addressed again at some point.
         *
         * - DR 23/1/2009
         *
         *
        registerVerticalScroll: function (header, content) {
            var _nav, _body, _offset, _spacer;
            _nav    = $(header);
            _body   = $(content);
            _offset = (Position.cumulativeOffset(_nav))[1];
            _spacer = '42px'; // The gap between nav & body to ensure smooth scrolling

//            DEBUG.write(yOffset);

            window.onscroll = function (e) {
                var e, srcollVals;
                e = e || window.event;

                srcollVals = TRAVELBUG.calendarUtils.getScrollXY();
                if (srcollVals['y'] > _offset) {
                    _nav.setStyle({ position : 'fixed' });
                    _body.setStyle({ marginTop: _spacer });

                    if(TRAVELBUG.guiUtils.isIE6) {
                        _nav.setStyle({ position: 'absolute', top: (srcollVals['y'] - 325)});
                        _body.setStyle({ position : 'static' });
                    }

                } else {

                    _nav.setStyle({ position : 'static', top: '0' });
                    _body.setStyle({ marginTop : '0' });

                    if(TRAVELBUG.guiUtils.isIE6) {
                        _nav.setStyle({ position: 'absolute'});
                        _body.setStyle({ marginTop: _spacer });
                    }
                }
            };
        }
        **/
    };
})();

/**
* Scrolling last minute deals calendar widget
*/
TRAVELBUG.dealsCalendar = (function() {
    // private variables
    var _DIV, _validDays, _visibleDays, _daysToScroll, _animationOptions, _templates, _nextArrows, _previousArrows, _animationSubjects, _safariWonkyElements, _columnWidth, _restPosition, _movePosition, _next, _previous;

    _DIV = document.createElement(TRAVELBUG.constants.DIV);
    _validDays = 28; // number of days the calendar covers
    _visibleDays = 9; // number of days showing at any one time
    _daysToScroll = 3; // number of days the calendar will scroll when the user clicks and arrow
    _animationOptions = {
        duration: 400
    };

    _templates = {
        id: { // _templates.id.date
            product: new Template('dc-product-#{productId}'),
            cells: new Template('dc-cells-#{productId}')
        },
        cell: { // _templates.cell.classNames.weekday
            classNames: {
                weekday: 'calendar-cell',
                weekend: 'calendar-cell-weekend'
            },
            content: {
                deal: new Template('<span class="calendar-price-deal"><a href="/visit/#{locationId}/#{locationName}/#{ymd}/#{productId}">$#{rate}</a></span>'),
                nodeal: new Template('<span class="calendar-price"><a href="/visit/#{locationId}/#{locationName}/#{ymd}/#{productId}">$#{rate}</a></span>'),
                novacancy: '<span class="calendar-no-vacancy">No<br />vacancy</span>'
            }
        },
        date: { // _templates.date.classNames.weekday
            classNames: {
                weekday: 'calendar-date',
                weekend: 'calendar-date-weekend'
            },
            content: new Template('#{dow}<strong>#{date}</strong>'),
            date: new Template('#{day} #{month}')
        }
    };

    // private methods
    var _getProductElementId, _getCellsElementId, _getProductIdFromElementId, _getLocationNameFromElement, _buildDates, _buildCell, _scrollNext, _scrollPrevious, _pruneViewport, _updateVisibleDates, _updateVisibleDatesNext, _updateVisibleDatesPrevious, _pruneViewportNext, _pruneViewportPrevious, _edges;

    /**
    * Return the ID for a product element
    *
    * @param string productId
    *
    * @return string
    */
    _getProductElementId = function(productId) {
        return _templates.id.product.evaluate({ productId: productId });
    };

    /**
    * Return the ID for a cells element
    *
    * @param string productId
    *
    * @return string
    */
    _getCellsElementId = function(productId) {
        return _templates.id.cells.evaluate({ productId: productId });
    };

    /**
    * Return a location ID from a "cells" element
    *
    * @param object element HTML element
    *
    * @return int
    */
    _getLocationIdFromElement = function(element) {
        var productElement, anchorElement, pieces, locationId;
        productElement = element.next('div.calendar-product');
        if ('undefined' === typeof productElement) {
            productElement = element.previous('div.calendar-product');
        }
        anchorElement = productElement.down('a');
        pieces = anchorElement.href.split(TRAVELBUG.constants.SLASH);
        return pieces[pieces.length - 2];
    };


    /**
    * Return a location Name from a "cells" element
    *
    * @param object element HTML element
    *
    * @return int
    */
    _getLocationNameFromElement = function(element) {
        var productElement, anchorElement, pieces, locationId;
        productElement = element.next('div.calendar-product');
        if ('undefined' === typeof productElement) {
            productElement = element.previous('div.calendar-product');
        }
        anchorElement = productElement.down('a');
        pieces = anchorElement.href.split(TRAVELBUG.constants.SLASH);
        return pieces[pieces.length - 1];
    };

    /**
    * Return a product ID from an HTML element ID
    *
    * @param string elementId
    *
    * @return string
    */
    _getProductIdFromElementId = function(elementId) {
        var pieces;
        pieces = elementId.split(TRAVELBUG.constants.DASH);
        return pieces[pieces.length - 1];
    };

    /**
    * Build one date element
    *
    * @param string ymd YYYY-MM-DD formatted date string
    *
    * @return object Hash of 2 date HTML strings, 1 for the top and 1 for the bottom position
    */
    _buildDates = function(ymd) {
        var dateNode, dow, date;
        dateNode = _DIV.cloneNode(false);
        dateNode.className = (TRAVELBUG.dates.isWeekendDay(ymd)) ? _templates.date.classNames.weekend : _templates.date.classNames.weekday;
        dow = (TRAVELBUG.dates.isToday(ymd)) ? 'Today' : TRAVELBUG.dates.getDow(ymd);
        date = _templates.date.date.evaluate({ month: TRAVELBUG.dates.getMonth(ymd), day: parseInt(ymd.substring(8, 10), 10) });
        dateNode.innerHTML = _templates.date.content.evaluate({ dow: dow, date: date });
        return dateNode;
    };

    /**
    * Build one cell of the calendar from JSON object of the following structure:
    * {
    *     r: 755, <-- rate as it will be displayed
    *     d: 1, <-- boolean for whether this is a "deal"
    *     v: 0 <-- boolean for whether there is vacancy on this date
    * }
    *
    * @param object node      Hash of values relating to one product on one day
    * @param string ymd       YYYY-MM-DD formatted date string
    * @param int    productId
    *
    * @return string
    */
    _buildCell = function(locationId, locationName, ymd, productId) {
        var cellNode, dataNode, content, cellId, structureTemplate, cellHTML;
        cellNode = _DIV.cloneNode(false);
        cellNode.className = (TRAVELBUG.dates.isWeekendDay(ymd)) ? _templates.cell.classNames.weekend : _templates.cell.classNames.weekday;
        if ('undefined' === typeof TRAVELBUG.dealsCalendar.dataCache[ymd]) { // the cell is outside the valid range
            content = '';
        } else {
            dataNode = TRAVELBUG.dealsCalendar.dataCache[ymd][productId];
            if ('undefined' !== typeof dataNode.r) dataNode.r = Math.ceil(dataNode.r);
            if (0 === dataNode.v) { // no vacancy
                content = _templates.cell.content.novacancy;
            } else if (0 === dataNode.d) { // not a deal
                content = _templates.cell.content.nodeal.evaluate({ locationId: locationId.toLowerCase(), locationName: locationName, ymd: ymd, productId: productId, rate: dataNode.r });
            } else { // a deal with vacancy
                content = _templates.cell.content.deal.evaluate({ locationId: locationId.toLowerCase(), locationName: locationName, ymd: ymd, productId: productId, rate: dataNode.r });
            }
        }
        cellNode.innerHTML = content;
        return cellNode;
    };

    /**
    * Execute the scroll
    *
    * @param int startPosition Where the animation subjects are now
    * @param int endPosition   Where we want the animation subjects to end up
    */
    _scroll = function(startPosition, endPosition) {
        var animator;
        animator = new Animator(_animationOptions).addSubject(new NumericalStyleSubject(_animationSubjects, TRAVELBUG.constants.LEFT, startPosition, endPosition));
        animator.seekTo(1);
    };

    /**
    * Scroll to show the next _daysToScroll days
    */
    _scrollNext = function() {
        _animationOptions.onComplete = function() {
            _pruneViewportNext();
            _edges.disable();
            TRAVELBUG.calendarUtils.fixSafari(_safariWonkyElements);
            TRAVELBUG.dealsCalendar.restoreEventListeners();
        };
        _edges.enable();
        _scroll(_restPosition, _movePosition);
    };

    /**
    * Scroll to show the previous _daysToScroll days
    */
    _scrollPrevious = function() {
        _animationOptions.onComplete = function() {
            _pruneViewportPrevious();
            _edges.disable();
            TRAVELBUG.calendarUtils.fixSafari(_safariWonkyElements);
            TRAVELBUG.dealsCalendar.restoreEventListeners();
        };
        _edges.enable();
        _scroll(_movePosition, _restPosition);
    };

    /**
    * Update the valid dates array to reflect the visible viewport
    *
    * @param string direction "next" | "previous"
    * @param int    numDays   Number of days to shift the array
    */
    _updateVisibleDates = function(direction, numDays) {
        var startDate;
        if (TRAVELBUG.constants.NEXT === direction) {
            startDate = TRAVELBUG.dates.ymd2obj(TRAVELBUG.dealsCalendar.datesVisible[TRAVELBUG.dealsCalendar.datesVisible.length - 1]);
            numDays.times(function(n) {
                TRAVELBUG.dealsCalendar.datesVisible[TRAVELBUG.dealsCalendar.datesVisible.length] = TRAVELBUG.dates.obj2ymd(startDate.addDays(1));
            });
            TRAVELBUG.dealsCalendar.datesVisible = TRAVELBUG.dealsCalendar.datesVisible.slice(_daysToScroll); // lop off first _daysToScroll elements
        } else { // TRAVELBUG.constants.PREVIOUS === direction
            startDate = TRAVELBUG.dates.ymd2obj(TRAVELBUG.dealsCalendar.datesVisible[0]);
            numDays.times(function(n) {
                TRAVELBUG.dealsCalendar.datesVisible[TRAVELBUG.dealsCalendar.datesVisible.length] = TRAVELBUG.dates.obj2ymd(startDate.addDays(-1));
            });
            TRAVELBUG.dealsCalendar.datesVisible.sort();
            TRAVELBUG.dealsCalendar.datesVisible = TRAVELBUG.dealsCalendar.datesVisible.slice(0, (0 - _daysToScroll)); // lop off last _daysToScroll elements
        }
    };

    /**
    * Shift the valid dates array by +numDays
    */
    _updateVisibleDatesNext = function(numDays) {
        numDays = numDays || _daysToScroll;
        _updateVisibleDates(TRAVELBUG.constants.NEXT, numDays);
    };

    /**
    * Shift the valid dates array by -numDays
    */
    _updateVisibleDatesPrevious = function(numDays) {
        numDays = numDays || _daysToScroll;
        _updateVisibleDates(TRAVELBUG.constants.PREVIOUS, numDays);
    };

    /**
    * Reduce the cells in the viewport to just the visible dates
    *
    * @param string direction   "next" | "previous"
    * @param int    numElements Number of elements to shift off the DOM array
    */
    _pruneViewport = function(direction, numElements) {
        var newAnimationSubjects;
        newAnimationSubjects = $A([]);
        // prune based on the number of DOM nodes, not IDs!
        if (TRAVELBUG.constants.NEXT === direction) {
            _animationSubjects.each(function(subject) {
                var clone, descendants;
                // deep clone subject
                clone = subject.cloneNode(true);
                descendants = clone.immediateDescendants();
                // lop off first numElements nodes
                numElements.times(function(n) {
                    var node;
                    node = descendants.shift();
                    clone.removeChild(node);
                });
                // set left value to _restPosition
                clone.setStyle({ left: _restPosition + TRAVELBUG.constants.PX });
                // replace subject with new DOM structure
                subject.parentNode.replaceChild(clone, subject);
                newAnimationSubjects[newAnimationSubjects.length] = clone;
            });
            _animationSubjects = newAnimationSubjects;
        } else if (TRAVELBUG.constants.PREVIOUS === direction) {
            _animationSubjects.each(function(subject) {
                var descendants;
                descendants = subject.immediateDescendants();
                // lop off last numElements nodes
                numElements.times(function(n) {
                    var node;
                    node = descendants.pop();
                    subject.removeChild(node);
                });
            });
        } else {
            throw ('PEBCAK: Variable "direction" is undefined');
        }
    };

    /**
    * Reduce the cells in the viewport to just the visible dates
    */
    _pruneViewportNext = function(numElements) {
        numElements = numElements || _daysToScroll;
        _pruneViewport(TRAVELBUG.constants.NEXT, numElements);
    };

    /**
    * Reduce the cells in the viewport to just the visible dates
    */
    _pruneViewportPrevious = function(numElements) {
        numElements = numElements || _daysToScroll;
        _pruneViewport(TRAVELBUG.constants.PREVIOUS, numElements);
    };

    /**
    * Handle enabling and disabling of calendar edge columns
    */
    _edges = (function() {
        // private variables
        var _textNode, _anchorNode;
        _textNode = document.createTextNode('');
        _anchorNode = document.createElement('A');
        // private methods
        var _getEdges, _getEdgesDisabled;
        // return all cells that live on the "edges" of the calendar
        _getEdges = function() {
            var edgeCells;
            edgeCells = [];
            $$('div#dc-scroll-body div.calendar-cells').each(function(cells) {
                var descendants, first, last;
                descendants = cells.immediateDescendants();
                first = descendants[0].down();
                last = descendants[descendants.length - 1].down();
                if (first && !first.className.match(/\-disabled/)) edgeCells[edgeCells.length] = first;
                if (last && !last.className.match(/\-disabled/)) edgeCells[edgeCells.length] = last;
            });
            return edgeCells;
        };
        // return all cells that have -disabled in the class name
        _getEdgesDisabled = function() {
            var edgeCells;
            edgeCells = $$('div#dc-scroll-body div.calendar-cells span.calendar-price-disabled');
            edgeCells.push($$('div#dc-scroll-body div.calendar-cells span.calendar-price-deal-disabled'));
            edgeCells.push($$('div#dc-scroll-body div.calendar-cells span.calendar-no-vacancy-disabled'));
            return edgeCells.flatten();
        };
        // public methods
        return {
            enable: function() {
                $A(_getEdgesDisabled()).each(function(cell) {
                    var href, a, textNode;
                    // do the hard part first, adding back in the link, then make it look like there's a link
                    // there's no href if it's no vacancy
                    if (cell.className != 'calendar-no-vacancy-disabled') {
                        a = _anchorNode.cloneNode(false);
                        a.href = cell.getAttribute('href');
                        a.innerHTML = cell.innerHTML.strip();
                        cell.innerHTML = '';
                        cell.appendChild(a);
                    }
                    cell.className = cell.className.replace(/\-disabled/, '');
                });
            },
            disable: function() {
                $A(_getEdges()).each(function(cell) {
                    var a, textNode;
                    // do the easy part first, getting it to look like there isn't a link, then actually take away the link
                    cell.className = cell.className + '-disabled';
                    a = cell.down();
                    // there's no "a" if it's no vacancy
                    if ('A' === a.tagName) {
                        cell.setAttribute('href', a.href);
                        textNode = _textNode.cloneNode(false);
                        textNode.nodeValue = a.innerHTML;
                        a.parentNode.replaceChild(textNode, a);
                    }
                });
            }
        };
    })();

    // public variables and methods
    return {
        // product IDs
        productIds: $A([]),

        // array of valid dates for the entire calendar (excluding edges)
        datesValid: $A([]),

        // array of valid dates for the current, visible range (including edges)
        datesVisible: $A([]),

        // hash of calendar cache data
        dataCache: $H({}),

        // important dates, written inline to the page (all in YYYY-MM-DD format)
        dates: $H({
            start: undefined,
            min: undefined,
            max: undefined
        }),

        /**
        * Initialise the deals calendar
        */
        init: function() {
            var visibleStartDate;
            _edges.disable();
            // grab element collections once to save cycles
            _nextArrows = $$('.calendar-arrow-next a');
            _previousArrows = $$('.calendar-arrow-previous a');
            _animationSubjects = [
                $$('div.calendar-dates'),
                $$('div.calendar-cells')
            ].flatten();
            // elements that have z-index issues in Safari 2
            _safariWonkyElements = [
                $$('.calendar-arrow-previous a'),
                $$('.calendar-arrow-next a'),
                $$('div.calendar-product'),
                $$('div.calendar-controls-corner'),
                $$('div.calendar-controls-text')
            ].flatten();
            // width of a column (same for both dates and cells)
            _columnWidth = _animationSubjects[0].down().getWidth();
            // left position of the animation subjects when they are at rest and need to be moved, respectively
            _restPosition = parseInt(_animationSubjects[0].getStyle('left'), 10);
            _movePosition = _restPosition - (_columnWidth * _daysToScroll);
            // calculate the dates for the valid and visible ranges
            visibleStartDate = TRAVELBUG.dates.obj2ymd((new Date()).addDays(-1));
            this.datesValid = TRAVELBUG.calendarUtils.calculateDateRange(visibleStartDate, _validDays + 2); // add 2, 1 for each edge
            this.datesVisible = TRAVELBUG.calendarUtils.calculateDateRange(visibleStartDate, _visibleDays);
            // grab the product IDs from the cache
            this.productIds = $H(TRAVELBUG.dealsCalendar.dataCache[this.datesValid[2]]).keys();
            // fix heights before enabling arrows
            TRAVELBUG.calendarUtils.fixHeights(this);
            // enable arrow clicking
            _next = TRAVELBUG.dealsCalendar.next.bindAsEventListener();
            _previous = TRAVELBUG.dealsCalendar.previous.bindAsEventListener();
            this.restoreEventListeners();
        },

        /**
        * User clicks a "next" arrow
        */
        next: function(event) {
            var nextDate, lastDate;
            // what would be the last visible date after the scroll
            nextDate = TRAVELBUG.dates.obj2ymd((TRAVELBUG.dates.ymd2obj(TRAVELBUG.dealsCalendar.datesVisible[TRAVELBUG.dealsCalendar.datesVisible.length - 1])).addDays(_daysToScroll));
            // if valid to scroll
            if (TRAVELBUG.dealsCalendar.datesValid.indexOf(nextDate) > -1) {
                // disable arrow clicking
                TRAVELBUG.dealsCalendar.removeEventListeners();
                lastDate = TRAVELBUG.dates.ymd2obj(TRAVELBUG.dealsCalendar.datesVisible[TRAVELBUG.dealsCalendar.datesVisible.length - 1]);
                // prepare animation subjects
                _daysToScroll.times(function(n) {
                    var thisDate, currentYmd;
                    thisDate = lastDate.addDays(1);
                    currentYmd = TRAVELBUG.dates.obj2ymd(thisDate);
                    // add HTML to the right of each date and cells div
                    _animationSubjects.each(function(subject) {
                        var productId, newNode, locationName;
                        if ('calendar-cells' === subject.className) {
                            locationId = _getLocationIdFromElement(subject);
                            productId = _getProductIdFromElementId(subject.id);
                            locationName = _getLocationNameFromElement(subject);
                            newNode = _buildCell(locationId, locationName, currentYmd, productId);
                        } else { // 'calendar-dates' === subject.className
                            newNode = _buildDates(currentYmd);
                        }
                        subject.appendChild(newNode);
                    });
                });
                // fix heights before we scroll
                TRAVELBUG.calendarUtils.fixHeights(TRAVELBUG.dealsCalendar);
                // update valid dates here because the prune method is called onComplete of the animation
                _updateVisibleDatesNext();
                // execute animation
                _scrollNext();
            }
            Event.stop(event);
        },

        /**
        * User clicks a "previous" arrow
        */
        previous: function(event) {
            var newAnimationSubjects, previousDate;
            newAnimationSubjects = $A([]);
            // what would be the first visible date after the scroll
            previousDate = TRAVELBUG.dates.obj2ymd((TRAVELBUG.dates.ymd2obj(TRAVELBUG.dealsCalendar.datesVisible[0])).addDays((0 - _daysToScroll)));
            // if valid to scroll
            if (TRAVELBUG.dealsCalendar.datesValid.indexOf(previousDate) > -1) {
                // disable arrow clicking
                TRAVELBUG.dealsCalendar.removeEventListeners();
                // prepare animation subjects
                _animationSubjects.each(function(subject) {
                    var firstDate, clone, innerHTML, newNode;
                    firstDate = TRAVELBUG.dates.ymd2obj(TRAVELBUG.dealsCalendar.datesVisible[0]);
                    // deep clone date and cells divs
                    clone = subject.cloneNode(true);
                    // set left position to _movePosition
                    clone.setStyle({ left: _movePosition + TRAVELBUG.constants.PX });
                    // add HTML to the left
                    if ('calendar-cells' === subject.className) {
                        _daysToScroll.times(function(n) {
                            var thisDate, currentYmd, locationId, locationName, productId;
                            thisDate = firstDate.addDays(-1);
                            currentYmd = TRAVELBUG.dates.obj2ymd(thisDate);
                            locationId = _getLocationIdFromElement(subject);
                            locationName = _getLocationNameFromElement(subject);
                            productId = _getProductIdFromElementId(subject.id);
                            newNode = _buildCell(locationId, locationName, currentYmd, productId);
                            clone.insertBefore(newNode, clone.firstChild);
                        });
                    } else { // 'calendar-dates' === subject.className
                        _daysToScroll.times(function(n) {
                            var thisDate, currentYmd;
                            thisDate = firstDate.addDays(-1);
                            currentYmd = TRAVELBUG.dates.obj2ymd(thisDate);
                            newNode = _buildDates(currentYmd);
                            clone.insertBefore(newNode, clone.firstChild);
                        });
                    }
                    // replace current nodes with clones
                    subject.parentNode.replaceChild(clone, subject);
                    newAnimationSubjects[newAnimationSubjects.length] = clone;
                });
                _animationSubjects = newAnimationSubjects;
                // fix heights before we scroll
                TRAVELBUG.calendarUtils.fixHeights(TRAVELBUG.dealsCalendar);
                // update valid dates here because the prune method is called onComplete of the animation
                _updateVisibleDatesPrevious();
                // execute animation
                _scrollPrevious();
            }
            Event.stop(event);
        },

        /**
        * Activate the "next" and "previous" arrows
        */
        restoreEventListeners: function() {
            _previousArrows.each(function(arrow) {
                arrow.observe('click', _previous);
            });
            _nextArrows.each(function(arrow) {
                arrow.observe('click', _next);
            });
        },

        /**
        * Deactivate the "next" and "previous" arrows
        */
        removeEventListeners: function() {
            _previousArrows.each(function(arrow) {
                arrow.stopObserving('click', _previous);
            });
            _nextArrows.each(function(arrow) {
                arrow.stopObserving('click', _next);
            });
        },

        /**
        * Return a product DOM element
        *
        * @param string productId
        *
        * @return object
        */
        getProductElement: function(productId) {
            var elementId;
            elementId = _getProductElementId(productId);
            return $(elementId);
        },

        /**
        * Return a cells DOM element
        *
        * @param string productId
        *
        * @return object
        */
        getCellsElement: function(productId) {
            var elementId;
            elementId = _getCellsElementId(productId);
            return $(elementId);
        },

        /**
        * Return a cell DOM element
        *
        * @param string ymd
        * @param string productId
        *
        * @return object
        */
        getCellElement: function(ymd, productId) {
            var elementId;
            elementId = _getCellElementId(ymd, productId);
            return $(elementId);
        }
    };
})();

/**
* Accommodation type checkboxes on search/browse results pages
*/
TRAVELBUG.accomTypeCheckboxes = (function () {
    // constants
    var _DASH, _TRUE, _FALSE, _HIDDEN, _IMGIDBASE, _CHECKEDSTRING;

    _DASH          = '-';
    _TRUE          = '1';
    _FALSE         = '0';
    _HIDDEN        = 'hidden';
    _IMGIDBASE     = 'tbcheckbox-img-';
    _CHECKEDSTRING = '-checked';

    // private variables
    var _selectedCheckboxValues, _elementIds, _templates, _checkboxes, _updateButton;

    _selectedCheckboxValues = $A([]);

    _elementIds = {
        container:      'tbcheckboxes-accomtype',
        checkboxValues: 'tbcheckboxes-values',
        updateButton:   'update-button'
    };

    _templates = {
        imgTag: new Template('<img id="#{id}" src="#{imgSrc}" alt="#{alt}" />'),
        imgSrcs: {
            base: '/images/',
            unselected: {
                lo: 'tbcheckbox.gif',
                hi: 'tbcheckbox_hi.gif'
            },
            selected: {
                lo: 'tbcheckbox-checked.gif',
                hi: 'tbcheckbox-checked_hi.gif'
            }
        }
    };

    _checkboxes = $A([]);

    // private methods
    var _handleEvents, _addCheckbox, _removeCheckbox, _saveSelectedToHidden, _enableButton, _disableButton;

    _handleEvents = (function () {
        // public methods
        return {
            on: {
                /**
                * Handle a user click on a checkbox
                */
                click: function (event) {
                    var pieces, value;
                    pieces = this.id.split(_DASH);
                    value  = pieces[pieces.length - 1];
                    if (this.src.indexOf(_CHECKEDSTRING) > -1) {
                        _removeCheckbox(value);
                        this.src = _templates.imgSrcs.base + _templates.imgSrcs.unselected.hi;
                    } else {
                        _addCheckbox(value);
                        this.src = _templates.imgSrcs.base + _templates.imgSrcs.selected.hi;
                    }
                    _saveSelectedToHidden();
                    Event.stop(event);
                }
            }
        };
    })();

    _selectCheckbox = function (checkbox) {
        var pieces, value;
        pieces = checkbox.id.split(_DASH);
        value  = pieces[pieces.length - 1];
        _addCheckbox(value);
        checkbox.src = _templates.imgSrcs.base + _templates.imgSrcs.selected.lo;
        _saveSelectedToHidden();
    };

    /**
    * Add a checkbox to the array of selected checkboxes
    *
    * @param string value Checkbox value
    */
    _addCheckbox = function (value) {
        _selectedCheckboxValues[_selectedCheckboxValues.length] = value;
        _selectedCheckboxValues = _selectedCheckboxValues.uniq();
    };

    /**
    * Remove a checkbox from the array of selected checkboxes
    *
    * @param string value Checkbox value
    */
    _removeCheckbox = function (value) {
        _selectedCheckboxValues = _selectedCheckboxValues.without(value);
    };

    /**
    * Save selected checkboxes to the hidden input
    */
    _saveSelectedToHidden = function () {
        $(_elementIds.checkboxValues).value = _selectedCheckboxValues.join(' ');
    };

    // public methods
    return {
        /**
        * Initialise the accommodation type checkboxes on search results
        */
        init: function () {
            _updateButton = $(_elementIds.updateButton);

            // local methods (why local? because they're used once then never needed again)
            var getCheckbox, getLabelText, createHiddenInput, addCheckboxImg, addEventHandlers;

            /**
            * Return the checkbox element
            *
            * @param object container HTML element containing the checkbox element
            *
            * @return object
            */
            getCheckbox = function (container) {
                var inputs;
                inputs = container.getElementsByTagName(TRAVELBUG.constants.INPUT);
                return inputs[0];
            };

            /**
            * Return the text from the label tag
            *
            * @param object container HTML element containing the checkbox element
            *
            * @return string
            */
            getLabelText = function (container) {
                var labelText;
                labelText = false;
                for (var i = 0, len = container.childNodes.length; i < len; ++i) {
                    if (container.childNodes[i].nodeType === 3 && (container.childNodes[i].nodeValue.strip()).length > 0) {
                        labelText = container.childNodes[i].nodeValue.strip();
                        break;
                    }
                }
                return labelText;
            };

            /**
            * Insert a checkbox-looking image where the checkbox used to be
            *
            * @param boolean checked   State of the checkbox
            * @param object  container The checkbox's container element
            * @param string  id        ID for the new element
            */
            addCheckboxImg = function (checked, container, id) {
                var imgSrc, alt, imgTag, insertion;

                imgSrc  = _templates.imgSrcs.base;
                imgSrc += (checked)? _templates.imgSrcs.selected.lo: _templates.imgSrcs.unselected.lo;
                alt     = getLabelText(container);
                imgTag  = _templates.imgTag.evaluate({id: id, imgSrc: imgSrc, alt: alt});

                // TODO make this put the image in the spot where the checkbox was, not necessarily at the top of the element
                insertion = new Insertion.Top(container, imgTag);

                _checkboxes.push($(id));
            };

            /**
            * Attach event handlers to the checkbox-looking image container
            *
            * @param string id Checkbox image ID
            */
            addEventHandlers = function (id) {
                var img;
                img = $(id);
                Event.observe(img.parentNode, TRAVELBUG.constants.MOUSEOVER, function () {
                    TRAVELBUG.guiUtils.images.hi(img);
                });
                Event.observe(img.parentNode, TRAVELBUG.constants.MOUSEOUT, function () {
                    TRAVELBUG.guiUtils.images.lo(img);
                });
                Event.observe(img.parentNode, TRAVELBUG.constants.CLICK, _handleEvents.on.click.bindAsEventListener(img));
            };
            // initialise each of the checkboxes
            $A($(_elementIds.container).getElementsByTagName(TRAVELBUG.constants.LABEL)).each(function (container) {
                var checkbox, value, checked, id;

                checkbox = getCheckbox(container);
                value    = checkbox.value;
                checked  = checkbox.checked;
                id       = _IMGIDBASE + value;

                // if the checkbox is selected, add it to our _selectedCheckboxValues array
                if (checked) {
                    _addCheckbox(value);
                }

                $(checkbox).remove();
                addCheckboxImg(checked, container, id);
                addEventHandlers(id);
            });

            _saveSelectedToHidden();
        },
        // Convenience method
        checkAll: function () {
            _checkboxes.each(function (checkbox) {
                _selectCheckbox(checkbox);
            });
        },
        // Return true if at least 1 checkbox is checked
        validate: function () {
            return ('' === $F(_elementIds.checkboxValues))? false: true;
        }
    };
})();

/**
* Slider price control on search/browse results pages
*/
TRAVELBUG.priceSlider = (function () {
    // constants
    var _HI, _LO, _IN, _OUT, _NONE, _LOWER, _UPPER, _OPACITY;

    _HI       = 'hi';
    _LO       = 'lo';
    _IN       = 'in';
    _OUT      = 'out';
    _NONE     = 'none';
    _OPEN     = 'open';
    _LOWER    = 'lower';
    _UPPER    = 'upper';
    _CLOSED   = 'closed';
    _OPACITY  = 'opacity';

    // private variables
    var _elementIds, _pixels, _templates, _cursors, _slider, _lowerLimit, _upperLimit, _valueIncrement, _lowerStart, _upperStart, _lowerInput, _upperInput, _priceDisplays, _sliderValues, _pxIncrement, _moveIncrement, _container, _track, _trackMask, _trackMaskLeft, _trackMaskRight, _handles, _updateButton;

    _elementIds = {
        updateButton : 'update-button',
        lowerInput   : 'tbslider-value-lower',
        upperInput   : 'tbslider-value-upper',
        track        : 'tbslider-track-price',
        trackMask    : 'tbslider-mask-price',
        handleLower  : 'tbslider-handle-lower-price',
        handleUpper  : 'tbslider-handle-upper-price',
        prices: $H({
            'tbslider-value-0' :  50,
            'tbslider-value-1' : 100,
            'tbslider-value-2' : 150,
            'tbslider-value-3' : 200,
            'tbslider-value-4' : 250,
            'tbslider-value-5' : 300
        }),
        priceDisplay: {
            lower: 'tbslider-price-display-lower',
            upper: 'tbslider-price-display-upper'
        }
    };

    // lookup table for converting dollar values to pixels
    _pixels = $H({});
    _pixels['50']  =   0;
    _pixels['75']  =  21;
    _pixels['100'] =  45;
    _pixels['125'] =  64;
    _pixels['150'] =  86;
    _pixels['175'] = 107;
    _pixels['200'] = 127;
    _pixels['225'] = 149;
    _pixels['250'] = 167;
    _pixels['275'] = 191;
    _pixels['300'] = 214;

    _templates = {
        styles: {
            clip:   new Template('rect(auto, #{rightValue}px, auto, #{leftValue}px)'),
            clipIE: new Template('rect(auto #{rightValue}px auto #{leftValue}px)')
        }
    };

    _cursors = {
        hand: {
            ie: {
                open   : 'url("/images/hand-open.cur"), pointer',
                closed : 'url("/images/hand-closed.cur"), pointer'
            },
            open   : 'pointer',
            closed : 'pointer'
        },
        deefalt: 'default'
    };

    // where we'll store slider values
    _slider = {
        values: {
            lower: undefined,
            upper: undefined
        },
        positions: {
            lower: undefined,
            upper: undefined
        },
        handleImages: {
            lower: undefined,
            upper: undefined
        }
    };

    // private methods
    var _updateFormValues, _updatePriceDisplays, _enableButton, _disableButton, _handleEvents;

    /**
     * Save handle values to the input elements
     */
    _updateFormValues = function () {
        _lowerInput.value = _slider.values.lower;
        _upperInput.value = _slider.values.upper;
    };

    /**
     * Update price display balloons with current slider values
     */
    _updatePriceDisplays = function () {
        var plus;
        plus = (300 === _slider.values.upper)? '+' : '';
        _priceDisplays.lower.update('$' + _slider.values.lower);
        _priceDisplays.upper.update('$' + _slider.values.upper + plus);
    };

    // handle user initiated events
    _handleEvents = (function () {
        // private variables
        var _mouseDown, _mouseOver, _boundEvent, _animations, _activeHandle, _x, _fadePriceDisplayTimer;

        _mouseDown = false;
        _mouseOver = false;

        // where we'll store functions, bound as event listeners
        _boundEvent = {
            mouseover: {
                upper: undefined,
                lower: undefined
            },
            mouseout: {
                upper: undefined,
                lower: undefined
            },
            mousedown: {
                upper: undefined,
                lower: undefined
            }
        };

        _animations = (function () {
            // private variables
            var _duration, _opacities;
            _duration = 200;
            _opacities = {
                hidden  : 0.0,
                visible : 0.99 // use 0.99 instead of 1.0 to avoid 1px shift ("feature" parity with Flash!)
            };
            // private methods
            var _assign;
            /**
             * Create a new Animator for use on a price display balloon
             * @param string idx Handle ID 'upper' | 'lower'
             */
            _assign = function (idx) {
                _animations[idx] = new Animator({duration: _duration}).addSubject(new NumericalStyleSubject(_priceDisplays[idx], _OPACITY, _opacities.hidden, _opacities.visible))
            };
            return {
                // public variables
                upper : undefined,
                lower : undefined,
                // public methods
                init  : function () {
                    _assign(_UPPER);
                    _assign(_LOWER);
                }
            }
        })();

        // private methods
        var _calculateX, _limitX, _calculateValue, _limitValue, _getClosestHandle, _getHandleIdx, _updateSlider, _updateHandleImage, _fadePriceDisplay, _getOtherElement;

        /**
         * Determines the numerical distance (no 'px') from the left edge of the slider mask
         * @param object event
         * @return int Number of pixels from the left edge of the slider mask element
         */
        _calculateX = function (event) {
            var x, offset;
            x = Event.pointerX(event);
            offset = Position.cumulativeOffset(_trackMask);
            return _limitX(x - offset[0]);
        };

        /**
         * Limit the x value to values within the track mask
         * @param int x Value (in pixels) from the left edge of the track mask?
         * @return int Value (in pixels) that is always within the track mask
         */
        _limitX = function (x) {
            return (x < _trackMaskLeft)? _trackMaskLeft: (x > _trackMaskRight)? _trackMaskRight: x;
        };

        /**
         * Calculate nearest valid value to a left pixel value
         * @param int x Value (in pixels) from the left of the track (track mask?)
         * @return int Value (in dollars)
         */
        _calculateValue = function (x) {
            var idx;
            x = parseInt(x, 10); // strip 'px' if present
            idx = Math.round(x / _pxIncrement);
            // be lenient around the left edge, otherwise we'll never be able to set it to 0
            if (x < 17) {
                idx = 0;
            }
            return _sliderValues[idx];
        };

        /**
         * Limit the handle value in a way that keeps 'lower' lower than 'upper'
         */
        _limitValue = function (handleIdx, handleValue) {
            if (_LOWER === handleIdx) {
                return (handleValue < _slider.values.upper)? handleValue: _slider.values.upper - _valueIncrement;
            } else { // _UPPER === handleIdx
                return (handleValue > _slider.values.lower)? handleValue: _slider.values.lower + _valueIncrement;
            }
        };

        /**
         * Determine the closest handle and return its index (ie., _LOWER or _UPPER)
         * @param int x
         * @return string Array key for the closest handle
         */
        _getClosestHandle = function (x) {
            var lowerDiff, upperDiff;
            lowerDiff = Math.abs(_slider.positions.lower - x);
            upperDiff = Math.abs(_slider.positions.upper - x);
            return lowerDiff < upperDiff ? _LOWER : _UPPER;
        };

        /**
         * Determine the handle index (ie., _LOWER or _UPPER) based on the target element
         * @param object element The HTML element in question
         * @return string Array key for the handle
         */
        _getHandleIdx = function (element) {
            if (element.id.indexOf(_LOWER) > -1 || element.parentNode.id.indexOf(_LOWER) > -1) {
                return _LOWER;
            } else if (element.id.indexOf(_UPPER) > -1 || element.parentNode.id.indexOf(_UPPER) > -1) {
                return _UPPER;
            } else {
                return undefined;
            }
        };

        /**
         * Update the position of handles and the values saved in the form
         * @param string handleIdx
         * @param int    handleValue
         */
        _updateSlider = function (handleIdx, handleValue) {
            _slider.values[handleIdx]    = handleValue;
            _slider.positions[handleIdx] = _pixels[handleValue];
            _updateFormValues();
            _updatePriceDisplays();
            _handleEvents.moveHandle(handleIdx);
            _handleEvents.adustMaskClip();
        };

        /**
         * Execute image rollovers for slider handles
         */
        _updateHandleImage = function (element, dir) {
            var otherElement;
            if (!_mouseDown) {
                if (_LO === dir && _mouseOver)  return;
                if (_HI === dir && !_mouseOver) return;
                otherElement = _getOtherElement(element);
                if (otherElement) TRAVELBUG.guiUtils.images[_LO](otherElement);
                TRAVELBUG.guiUtils.images[dir](element);
            }
        };

        /**
         * Fade in/out a price display balloon
         */
        _fadePriceDisplay = function (element, dir) {
            var otherElement, styleObj, options, seekTo;
            if (!_mouseDown) {
                if (_OUT === dir && _mouseOver)  return;
                if (_IN  === dir && !_mouseOver) return;
                otherElement = _getOtherElement(element);
                if (TRAVELBUG.guiUtils.isIE) { // make display binary for IE
                    if (otherElement) otherElement.setStyle({display: 'none'});
                    styleObj = {};
                    styleObj.display = (_OUT === dir)? 'none' : 'block';
                    element.setStyle(styleObj);
                } else { // give nice transition effect to better browsers
                    options = {
                        duration: 200
                    };
                    if (otherElement) otherElement.setStyle({opacity: 0.0});
                    seekTo    = (_OUT === dir)? 0 : 1;
                    _animations[_getHandleIdx(element)].seekTo(seekTo);
                }
            }
        };

        /**
         * Get the inverse element for a given element (e.g., div#xyz-upper>img returns div#xyz-lower>img)
         */
        _getOtherElement = function (element) {
            var id, otherElement, parentElement;
            if (element.id.indexOf(_LOWER) > -1 || element.id.indexOf(_UPPER) > -1) {
                id = element.id.indexOf(_LOWER) > -1 ? element.id.replace(_LOWER, _UPPER) : element.id.replace(_UPPER, _LOWER);
                otherElement = $(id);
            } else if (element.parentNode.id.indexOf(_LOWER) > -1 || element.parentNode.id.indexOf(_UPPER) > -1) { // look at the parent element, if this one doesn't have 'upper' or 'lower' in the ID
                // TODO make this more generalised to eliminate dependency on a particular DOM structure
                id = element.parentNode.id.indexOf(_LOWER) > -1 ? element.parentNode.id.replace(_LOWER, _UPPER) : element.parentNode.id.replace(_UPPER, _LOWER);
                parentElement = $(id);
                otherElement  = (parentElement.descendants())[0]; // structure is div>img
            } else {
                return false;
            }
            return otherElement;
        };

        /**
         * Set the state of the cursor when interacting with the price slider handle
         *
         * @param string state Whether we're setting the cursor to open or closed
         */
        _setCursor = function (state) {
            var cursorStyle;
            cursorStyle        = {cursor: undefined};
            cursorStyle.cursor = (_OPEN === state)? _cursors.deefalt : (TRAVELBUG.guiUtils.isIE)? _cursors.hand.ie[state]: _cursors.hand[state];
            _handles[_activeHandle].setStyle({cursor: (TRAVELBUG.guiUtils.isIE)? _cursors.hand.ie[state]: _cursors.hand[state]});
            _container.setStyle(cursorStyle);
            _track.setStyle(cursorStyle);
            _trackMask.setStyle(cursorStyle);
            $(document.body).setStyle(cursorStyle);
        };

        // public methods
        return {
            init: function () {
                // create the animations
                _animations.init();

                // bind functions to handles
                _boundEvent.mouseover.lower = _handleEvents.on.mouseover.bindAsEventListener(_slider.handleImages.lower);
                _boundEvent.mouseover.upper = _handleEvents.on.mouseover.bindAsEventListener(_slider.handleImages.upper);
                _boundEvent.mouseout.lower  = _handleEvents.on.mouseout.bindAsEventListener(_slider.handleImages.lower);
                _boundEvent.mouseout.upper  = _handleEvents.on.mouseout.bindAsEventListener(_slider.handleImages.upper);
                _boundEvent.mousedown.lower = _handleEvents.on.mousedown.bindAsEventListener(_slider.handleImages.lower);
                _boundEvent.mousedown.upper = _handleEvents.on.mousedown.bindAsEventListener(_slider.handleImages.upper);

                // cancel default events in IE
                if (TRAVELBUG.guiUtils.isIE) {
                    Event.observe(_handles.lower, TRAVELBUG.constants.DRAGSTART, _handleEvents.cancelEvent);
                    Event.observe(_handles.upper, TRAVELBUG.constants.DRAGSTART, _handleEvents.cancelEvent);
                }

                // use event handlers everyone can understand
                Event.observe(_handles.lower, TRAVELBUG.constants.MOUSEDOWN, _boundEvent.mousedown.lower);
                Event.observe(_handles.upper, TRAVELBUG.constants.MOUSEDOWN, _boundEvent.mousedown.upper);

                // attach event listeners to track and track mask
                Event.observe(_track,     TRAVELBUG.constants.CLICK, _handleEvents.on.click);
                Event.observe(_trackMask, TRAVELBUG.constants.CLICK, _handleEvents.on.click);

                // attach image rollovers to handles
                Event.observe(_slider.handleImages.lower, TRAVELBUG.constants.MOUSEOVER, _boundEvent.mouseover.lower);
                Event.observe(_slider.handleImages.upper, TRAVELBUG.constants.MOUSEOVER, _boundEvent.mouseover.upper);
                Event.observe(_slider.handleImages.lower, TRAVELBUG.constants.MOUSEOUT,  _boundEvent.mouseout.lower);
                Event.observe(_slider.handleImages.upper, TRAVELBUG.constants.MOUSEOUT,  _boundEvent.mouseout.upper);
            },
            on: {
                /**
                 * Handle user clicking on the track mask
                 */
                click: function (event) {
                    var x, handleIdx;
                    x           = _calculateX(event);
                    handleIdx   = _getClosestHandle(x);
                    handleValue = _calculateValue(x);
                    handleValue = _limitValue(handleIdx, handleValue);
                    _updateSlider(handleIdx, handleValue);
                    Event.stop(event);
                },
                /**
                 * Handle user moving mouse over a handle
                 */
                mouseover: function (event) {
                    var el, handleIdx;
                    // IE, are we *really* over the element?
                    if (TRAVELBUG.guiUtils.isIE) {
                        el = Event.element(event);
                        // return if we're not actually over a handle element
                        if (el.parentElement.id.indexOf('tbslider-handle-') < 0) return;
                    }
                    handleIdx = _getHandleIdx(this);
                    _mouseOver = true;
                    _updateHandleImage(_slider.handleImages[handleIdx], _HI);
                    _fadePriceDisplay(_priceDisplays[handleIdx], _IN);
                    clearTimeout(_fadePriceDisplayTimer);
                    _fadePriceDisplayTimer = setTimeout(function () { _fadePriceDisplay(_priceDisplays[handleIdx], _OUT); }, 1000);
                },
                /**
                 * Handle user moving mouse out from over a handle
                 */
                mouseout: function (event) {
                    var handleIdx;
                    handleIdx = _getHandleIdx(this);
                    _mouseOver = false;
                    _updateHandleImage(_slider.handleImages[handleIdx], _LO);
                    _fadePriceDisplay(_priceDisplays[handleIdx], _OUT);
                },
                /**
                 * Handle user clicking down on a handle (Mozilla, Safari, Opera)
                 */
                mousedown: function (event) {
                    if (Event.isLeftClick(event) && !_mouseDown) {
                        _mouseDown    = true;
                        _activeHandle = _getHandleIdx(this);
                        _setCursor(_CLOSED);
                        _x = Event.pointerX(event); // store the raw initial value of the click
                        Event.observe(document, TRAVELBUG.constants.MOUSEMOVE, _handleEvents.on.mousemove);
                        Event.observe(document, TRAVELBUG.constants.MOUSEUP,   _handleEvents.on.mouseup);
                        Event.stop(event);
                    }
                },
                /**
                * Handle user moving the mouse after clicking down on a handle (Mozilla, Safari, Opera)
                */
                mousemove: function (event) {
                    var x, handleValue;
                    if (undefined !== _activeHandle && _mouseDown) {
                        x = Event.pointerX(event);
                        if (Math.abs(_x - x) < _moveIncrement) {
                            return;
                        } else {
                            _x = x; // store raw x for later comparison
                            x           = _calculateX(event); // calculate valid x value
                            handleValue = _calculateValue(x);
                            handleValue = _limitValue(_activeHandle, handleValue);
                            _updateSlider(_activeHandle, handleValue);
                        }
                    }
                    if (!TRAVELBUG.guiUtils.isIE) {
                        Event.stop(event);
                    }
                },
                /**
                * Handle user releasing the mouse after clicking down on a handle (Mozilla, Safari, Opera)
                */
                mouseup: function (event) {
                    _setCursor(_OPEN);
                    _mouseDown    = false;
                    _fadePriceDisplay(_priceDisplays[_activeHandle], _OUT);
                    _activeHandle = undefined;
                    Event.stopObserving(document, TRAVELBUG.constants.MOUSEMOVE, _handleEvents.on.mousemove);
                    Event.stopObserving(document, TRAVELBUG.constants.MOUSEUP,   _handleEvents.on.mouseup);
                    Event.stop(event);
                }
            },
            /**
            * Move a handle to a new position (assumes value is already set)
            *
            * @param string handleIdx Array key of the handle being moved
            */
            moveHandle: function (handleIdx) {
                var styleObj;
                styleObj = {
                    left: _slider.positions[handleIdx] + TRAVELBUG.constants.PX
                };
                _handles[handleIdx].setStyle(styleObj);
            },
            /**
             * Change the track mask to match the handle positions
             */
            adustMaskClip: function () {
                var rightValue, leftValue, template, clipStyle;
                rightValue = _pixels[_slider.values.upper];
                leftValue  = _pixels[_slider.values.lower];
                template   = TRAVELBUG.guiUtils.isIE ? _templates.styles.clipIE : _templates.styles.clip;
                clipStyle  = template.evaluate({rightValue: rightValue, leftValue: leftValue});
                _trackMask.setStyle({clip: clipStyle});
            },
            /**
             * IE-only function to prevent "no-drop" cursor when dragging
             */
            cancelEvent: function () {
                if (window.event) {
                    window.event.cancelBubble = true;
                    window.event.returnValue  = false;
                }
            }
        };
    })();

    // public methods
    return {
        /**
         * Initalise the price slider on search results
         *
         * @param int lowerLimit     Lower limit for the price slider
         * @param int upperLimit     Upper limit for the price slider
         * @param int valueIncrement Amount between each slider tick
         * @param int lowerStart     Start position of the lower handle (optional)
         * @param int upperStart     Start position of the upper handle (optional)
         */
        init: function (lowerLimit, upperLimit, valueIncrement, lowerStart, upperStart) {
            // save initial values to private variables for use by setToMax method
            _lowerLimit     = lowerLimit;
            _upperLimit     = upperLimit;
            _valueIncrement = valueIncrement;
            _lowerStart     = lowerStart || lowerLimit;
            _upperStart     = upperStart || upperLimit;

            // save needed object references
            _container      = ($$('div.tbsearchcontrols'))[0];
            _updateButton   = $(_elementIds.updateButton);
            _lowerInput     = $(_elementIds.lowerInput);
            _upperInput     = $(_elementIds.upperInput);
            _track          = $(_elementIds.track);
            _trackMask      = $(_elementIds.trackMask);
            _trackMaskWidth = _trackMask.getWidth() - 1;

            // if the container is absolutely positioned, values are relative to its left edge
            if ('absolute' === $('tbslider-price').getStyle('position')) {
                _trackMaskLeft  = 0;
                _trackMaskRight = _trackMaskWidth;
            } else {
                _trackMaskLeft  = (Position.cumulativeOffset(_trackMask))[0];
                _trackMaskRight = (Position.cumulativeOffset(_trackMask))[0] + _trackMaskWidth;
            }
            _handles = {
                lower: $(_elementIds.handleLower),
                upper: $(_elementIds.handleUpper)
            };
            _slider.handleImages.lower = (_handles.lower.descendants())[0];
            _slider.handleImages.upper = (_handles.upper.descendants())[0];
            _priceDisplays = {
                lower: $(_elementIds.priceDisplay.lower),
                upper: $(_elementIds.priceDisplay.upper)
            };

            // IE is stupid, so we'll use display: none | block instead of opacity
            if (TRAVELBUG.guiUtils.isIE) {
                _priceDisplays.lower.setStyle({display: 'none'});
                _priceDisplays.upper.setStyle({display: 'none'});
            } else {
                _priceDisplays.lower.setStyle({opacity: 0.0});
                _priceDisplays.upper.setStyle({opacity: 0.0});
            }

            // local methods (why local? because they're used once then never needed again)
            var calculateValues;

            /**
             * Calculate valid values for the slider
             *
             * @return array Array of user-selectable integer values
             */
            calculateValues = function () {
                var sliderValues, limit;
                sliderValues = [];
                limit = (1 * _upperLimit) + 1; // Cast upper as an int before adding 1
                for (var v = _lowerLimit; v < limit; v = v + _valueIncrement) {
                    sliderValues[sliderValues.length] = v;
                }
                return sliderValues;
            };

            // calculate valid values for the slider
            _sliderValues = calculateValues();

            // calculate the distance in pixels between each value jump (don't round it because we'll be comparing to floats anyway)
            _pxIncrement = _trackMaskWidth / (_sliderValues.length - 1);

            // calculate the distance in pixels the mouse needs to move before we jump to the next value (don't round)
            _moveIncrement = _pxIncrement / 2;

            // Explicitly set the initial state
            _slider.values[_LOWER]    = _lowerStart;
            _slider.values[_UPPER]    = _upperStart;
            _slider.positions[_LOWER] = _pixels[_slider.values[_LOWER]];
            _slider.positions[_UPPER] = _pixels[_slider.values[_UPPER]];
            _updateFormValues();
            _updatePriceDisplays();
            _handleEvents.moveHandle(_LOWER);
            _handleEvents.moveHandle(_UPPER);
            _handleEvents.adustMaskClip();

            // enable the slider
            _handleEvents.init();
        },
        /**
         * Set the slider to the maximum range
         */
        setToMax: function () {
            if (undefined !== _lowerLimit && undefined !== _upperLimit && undefined !== _valueIncrement) {
                TRAVELBUG.priceSlider.init(_lowerLimit, _upperLimit, _valueIncrement);
            }
        }
    };
})();

/**
 * Convenience methods to reset both price slider and accom types and to validate filter state
 */
TRAVELBUG.filters = (function () {
    return {
        validate: function () {
            var valid;
            valid = true;
            if (false === TRAVELBUG.accomTypeCheckboxes.validate()) {
                valid = false;
                alert('Please select at least one accommodation type.');
            }
            return valid;
        },
        reset: function () {
            TRAVELBUG.priceSlider.setToMax();
            TRAVELBUG.accomTypeCheckboxes.checkAll();
        }
    };
})();

/**
 * Handle the click of a "Save to Shortlist" button
 */
TRAVELBUG.shortlist = (function() {
    // private variables
    var _template, _buttonSizes, _timers;

    _template = new Template('<img alt="" src="/images/#{buttonSize}" />');
    _buttonSizes = {
        L: 'btn_shortlist_saved_l.gif',
        M: 'btn_shortlist_saved_m.gif',
        T: '<span class="shortlist-saved">Saved to Shortlist</span>' // text version used on search results cards
    };
    _timers = $A([]);

    // public methods
    return {
        /**
        * Add a location to the user's shortlist
        *
        * @param int     locationId
        * @param string  buttonSize
        * @param string  callback    String that is evaluated
        * @param boolean map         Boolean for whether we will be returning the user to a map view
        */
        add: function(locationId, buttonSize, callback, map) {
            var url, request;
            if ('T' === buttonSize) {
                $('save-to-shortlist-' + locationId).innerHTML = '<span class="shortlist-saving">Saving...</span>';
            }
            _timers[locationId] = (new Date()).getTime();
            url = '/shortlist/add/' + locationId + '/' + buttonSize;

            request = new Ajax.Request(
                url,
                {
                    onSuccess: function(transport) {
                        var begin, end, remaining, json;
                        json = transport.responseText.jsonify();
                        if (json) {
                            begin = _timers[json.locationId];
                            end = (new Date()).getTime();
                            remaining = 1000 - (end - begin);
                            if (remaining < 0) {
                                remaining = 0;
                            }
                            setTimeout(function() {
                                if ('T' === json.buttonSize) {
                                    $('save-to-shortlist-' + json.locationId).innerHTML = _buttonSizes[json.buttonSize];
                                }
                                else if ('L' === json.buttonSize) {
                                    $('save-to-shortlist-' + json.locationId).innerHTML = _template.evaluate({ buttonSize: _buttonSizes[json.buttonSize] });
                                }

                                // Dynamically update the shortlist navigation button 
                                // because we've just added another location to the shortlist.
                                var shortlistIndex = json.shortlistCount > 10 ? 11 : json.shortlistCount;

                                // Update current shortlist button image source to appropriate
                                // Off image.

                                if ('undefined' !== typeof $$('#shortlist-nav-link a')) {
                                    $('shortlist-nav-link-anchor').className = 'shortlist' + shortlistIndex;
                                }

                                if ('undefined' !== typeof callback) {
                                    eval(callback); // yes, eval is evil
                                }
                            }, remaining);
                        }
                    },
                    onFailure: function() {
                        //         window.location.href = '/login';
                    }
                }
            );
        }
    };
})();

/**
 * Handle comments on location pages
 */
TRAVELBUG.comments = (function () {
    return {
        /**
         * Show the user how many characters remain for her comment
         *
         * @param object field      Textarea element being edited
         * @param object countfield DIV displaying the number of remaining characters
         * @param int    maxlimit   Max number of characters for the field
         */
        textCounter: function (field, countfield, maxlimit) {
            if (field && countfield && maxlimit) {
                if (field.value.length > maxlimit) {
                    field.value = field.value.substring(0, maxlimit);
                } else {
                    countfield.innerHTML = (maxlimit - field.value.length) + ' characters remaining';
                }
            }
        }
    };
})();

/**
 * Handle maps set-up and interactions
 * Usage: requires writing certain variables to the page
 *        1. TRAVELBUG.maps.divId
 *        2. TRAVELBUG.maps.type (a string containing one of the following: search | modal | mini)
 *        3. Either TRAVELBUG.maps.XML (a string to be converted into XML with location data, used with type = 'search')
 *           or TRAVELBUG.maps.coords (an object literal containing lat/lng for a single marker, used with type = 'modal' | 'mini')
 */
TRAVELBUG.maps = (function() {
    // constants
    var MINI, MODAL, SEARCH, CLICK, MOUSEOVER, MOUSEOUT, LO, HI, PIN, BLOCK, FALSEY, DISPLAY, HOVERED, UNDEFINED;

    MINI = TRAVELBUG.constants.MINI;
    MODAL = TRAVELBUG.constants.MODAL;
    SEARCH = TRAVELBUG.constants.SEARCH;
    CLICK = TRAVELBUG.constants.CLICK;
    MOUSEOVER = TRAVELBUG.constants.MOUSEOVER;
    MOUSEOUT = TRAVELBUG.constants.MOUSEOUT;
    INFOWINDOWCLOSE = 'infowindowclose';
    LO = 'lo';
    HI = 'hi';
    PIN = 'pin';
    BLOCK = 'block';
    FALSEY = 'false';
    DISPLAY = 'display';
    MAPITEM = 'map-list-item';
    HOVERED = 'map-list-item-hovered';
    UNDEFINED = 'undefined';
    DEFAULT_ZOOM = 15;

    // private variables
    var _imgs, _tmpls, _xmlTags, _iconElements, _markers, _infoWinParent, _nowShowing, _timer;

    _imgs = {
        pinBlank: '/images/pins-00-lo.png',
        pinIcon: '/images/pin-transparent.png',
        pinShadow: '/images/pin-shadow.png',
        saveToFavs: '/images/btn_shortlist_m.gif',
        saveToFavsHover: '/images/btn_shortlist_m_hover.gif',
        savingToFavs: '/images/btn_shortlist_saving_m.gif',
        savedToFavs: '/images/btn_shortlist_saved_m.gif'
    };

    _tmpls = {
        // requires: locationId, locationName, imgSrc, price, fromPrice, normallyPrice, fav
        baseContent: new Template('<div id="tb-infowin" style="width: 250px; padding-top: 7px"><a href="/visit/#{locationId}"><img id="tb-infowin-photo" src="#{imgSrc}" alt="" /></a><div class="tb-infowin-location"><a class="tb-infowin-location" href="/visit/#{locationId}">#{locationName}</a></div><div class="tb-infowin-price">#{price}</div><div class="tb-infowin-price-from">#{fromPrice}</div><div class="tb-infowin-price-normally">#{normallyPrice}</div><div id="save2shortlist-#{locationId}" class="tb-infowin-shortlist">#{fav}</div></div>'),
        // requires: nuttin'
        shortlist: '<img id="tb-infowin-btn-shortlist" src="/images/btn_shortlist_saved_m.gif" alt="Shortlisted" />',
        // requires: locationId
        notShortlisted: new Template('<a href="#" onclick="TRAVELBUG.maps.addToShortlist(this, \'#{locationId}\');return false;"><img id="save-to-shortlist-#{locationId}" onmouseover="this.src=TRAVELBUG.maps.getImgSaveHover();" onmouseout="this.src=TRAVELBUG.maps.getImgSave();" src="/images/btn_shortlist_m.gif" alt="" /></a>')
    };

    _xmlTags = {
        FAV: 'fav',
        LAT: 'lat',
        LNG: 'lng',
        IMGSRC: 'imgSrc',
        PINSRC: 'flagicon',
        PRICE: 'price',
        PRICEFROM: 'fromPrice',
        PRICENORM: 'normallyPrice',
        LOCID: 'locationId',
        LOCNAME: 'locationName'
    };

    _iconElements = [];
    _markers = [];

    // private methods
    var _preloadImgs, _getNodeValue, _getIconElements, _getListItemFromMarker, _getMarkerImgFromListItem, _getMarkerFromLocationId, _makeIcon, _makeInfoWindowHTML, _infoWinIsShowing, _addEventListeners, _onClick, _onMouseover, _onMouseout, _makeMarker;

    _preloadImgs = function() {
        $H(_imgs).each(function(imgSrc) {
            var img;
            img = new Image();
            img.src = imgSrc.value;
        });
    };

    /**
    * Return the value of an XML node
    * @param DOM    xml Chunk of XML DOM
    * @param string nodeName Name of the node for which we want the value
    */
    _getNodeValue = function(xml, nodeName) {
        var value;
        value = xml.getElementsByTagName(nodeName)[0].firstChild.nodeValue;
        return value;
    };

    /**
    * Return an extended array of DOM IMG elements corresponding to the marker pins
    */
    _getIconElements = function() {
        var iconElements;
        iconElements = $A($$('div#viewport')[0].childNodes[0].childNodes[0].childNodes[6].childNodes);
        return iconElements;
    };

    /**
    * Return a list item based on a marker
    */
    _getListItemFromMarker = function(marker) {
        var id;
        id = 'list_' + marker.getIcon().image.split('-')[1];
        return $(id);
    };

    /**
    * Return a marker img element based on a list item
    */
    _getMarkerImgFromListItem = function(li) {
        var imgSrc, imgElement;
        imgSrc = 'pin-' + li.id.split('_')[1];
        _iconElements.each(function(img) {
            if (img.src.indexOf(imgSrc) > -1) {
                imgElement = img;
                throw $break;
            }
        });
        return imgElement;
    };

    /**
    * Return a marker map object based on a location ID
    * @param int locationId
    */
    _getMarkerFromLocationId = function(locationId) {
        var markerObject;
        $A(_markers).each(function(marker) {
            // cast both as ints to ensure a valid comparison
            if (+locationId === +marker.locationId) {
                markerObject = marker;
                throw $break;
            }
        });
        return markerObject;
    };

    /**
    * Return true or false depending on whether the info window is showing or not
    */
    _infoWinIsShowing = function() {

        return $('tb-infowin') !== null;
    };

    /**
    * Attach event listeners to a marker and its corresponding list item
    * @param object marker Marker map object
    */
    _addEventListeners = function(marker) {
        var li;
        li = _getListItemFromMarker(marker);
        // add event listeners to marker
        GEvent.addListener(marker, CLICK, function() { _onClick(marker, li); });
        GEvent.addListener(marker, MOUSEOVER, function() { _onMouseover(marker, li); });
        GEvent.addListener(marker, MOUSEOUT, function() { _onMouseout(li); });
        GEvent.addListener(marker, INFOWINDOWCLOSE, function() { _onMouseout(li); });
        // add event listeners to list item
        Event.observe(li, CLICK, function() { _onClick(marker, li); });
        Event.observe(li, MOUSEOVER, function() { _onMouseover(marker, li); });
        Event.observe(li, MOUSEOUT, function() { _onMouseout(li); });
    };

    /**
    * Handle user clicking a marker or list item
    * @param object      marker Marker map object
    * @param DOM element li     List item DOM element
    */
    _onClick = function(marker, li) {
        clearTimeout(_timer);
        _nowShowing = marker;
        marker.openInfoWindowHtml(marker.html);
        _onMouseover(marker, li);
    };

    /**
    * Handle user moving her mouse over a marker or list item
    * @param object      marker Marker map object
    * @param DOM element li     List item DOM element
    */
    _onMouseover = function(marker, li) {
        clearTimeout(_timer);
        // wrap this in a setTimeout so we don't overload the user's CPU if she's going nutso over the list
        setTimeout(function() {
            if (TRAVELBUG.guiUtils.isIE6) {
                _iconElements.each(function(div) {
                    if (div.style.filter.indexOf(marker.getIcon().image) > -1) {
                        div.style.filter = div.style.filter.replace(/lo/, HI);
                        throw $break;
                    }
                });
            }
            else {
                _iconElements.each(function(img) {
                    if (img.src.indexOf(marker.getIcon().image) > -1) {
                        img.src = img.src.replace(/lo/, HI);
                        throw $break;
                    }
                });
            }

            li.className = HOVERED;
        }, 10);
    };

    /**
    * Handle user moving her mouse out from over a marker or list item
    * @param DOM element li     List item DOM element
    */
    _onMouseout = function(li) {
        clearTimeout(_timer);
        // wrap this in a setTimeout so we don't overload the user's CPU if she's going nutso over the list
        setTimeout(function() {
            var isShowing, imgSrc, hiLi;
            isShowing = _infoWinIsShowing();
            imgSrc = (isShowing) ? _nowShowing.getIcon().image.replace(/lo/, HI) : false;

            if (TRAVELBUG.guiUtils.isIE6) {
                _iconElements.each(function(div) {
                    // do our best to short-circuit the indexOf comparison
                    if (isShowing && imgSrc && div.style.filter.indexOf(imgSrc) > -1) {
                        return;
                    } else {
                        div.style.filter = div.style.filter.replace(/hi/, LO);
                    }
                });
            }
            else {
                _iconElements.each(function(img) {
                    // do our best to short-circuit the indexOf comparison
                    if (isShowing && imgSrc && img.src.indexOf(imgSrc) > -1) {
                        return;
                    } else {
                        img.src = img.src.replace(/hi/, LO);
                    }
                });
            }

            if (isShowing) hiLi = _getListItemFromMarker(_nowShowing);
            $$('div.' + HOVERED).each(function(mli) {
                if (!isShowing || hiLi.id !== mli.id) { //
                    mli.className = MAPITEM;
                }
            });
        }, 10);
    };

    /**
    * Make a map icon based on an XML snippet
    * @param string src Path to the icon pin image
    * @return icon
    */
    _makeIcon = function(src) {
        var icon;
        icon = new GIcon();
        icon.image = src;
        icon.shadow = _imgs.pinShadow;
        icon.iconSize = new GSize(22, 36);
        icon.shadowSize = new GSize(54, 41);
        icon.iconAnchor = new GPoint(20, 36);
        icon.infoWindowAnchor = new GPoint(22, 0);
        icon.transparent = _imgs.pinIcon;
        icon.imageMap = [22, 0, 22, 36, 17, 36, 0, 13, 0, 7, 5, 2, 12, 0];
        return icon;
    };

    /**
    * Make info window HTML based on an XML snippet
    * @param DOM xml Chunk of XML DOM
    * @return HTML
    */
    _makeInfoWindowHTML = function(xml) {
        var locationId, locationName, imgSrc, price, fromPrice, normallyPrice, favValue, fav, html;
        locationId = _getNodeValue(xml, _xmlTags.LOCID);
        locationName = (_getNodeValue(xml, _xmlTags.LOCNAME)).truncate(30, '...');
        imgSrc = _getNodeValue(xml, _xmlTags.IMGSRC);
        price = _getNodeValue(xml, _xmlTags.PRICE);
        fromPrice = _getNodeValue(xml, _xmlTags.PRICEFROM);
        normallyPrice = _getNodeValue(xml, _xmlTags.PRICENORM);
        favValue = _getNodeValue(xml, _xmlTags.FAV);
        fav = (FALSEY === favValue) ? _tmpls.notShortlisted.evaluate({ locationId: locationId }) : _tmpls.shortlist;
        html = _tmpls.baseContent.evaluate({
            locationId: locationId,
            locationName: locationName,
            imgSrc: imgSrc,
            price: (FALSEY === price) ? '' : price,
            fromPrice: (FALSEY === fromPrice) ? '' : fromPrice,
            normallyPrice: (FALSEY === normallyPrice) ? '' : normallyPrice,
            fav: fav
        });
        return html;
    };

    /**
    * Make a map marker based on an XML snippet
    * @param DOM    xml Chunk of XML DOM
    * @param object map The map object
    * @return object Return an object literal with 2 members: the marker and the the point (used to extend the map bounds)
    */
    _makeMarker = function(xml, map) {
        var src, icon, lat, lng, locationId, point, marker, li;
        src = _getNodeValue(xml, _xmlTags.PINSRC);
        icon = _makeIcon(src);
        lat = _getNodeValue(xml, _xmlTags.LAT);
        lng = _getNodeValue(xml, _xmlTags.LNG);
        locationId = _getNodeValue(xml, _xmlTags.LOCID);
        point = new GLatLng(parseFloat(lat), parseFloat(lng));
        marker = new GMarker(point, icon);
        marker.html = _makeInfoWindowHTML(xml);
        marker.locationId = locationId;
        _markers[_markers.length] = marker;
        map.addOverlay(marker);
        _addEventListeners(marker);
        return {
            marker: marker,
            point: point
        };
    };

    return {
        divId: false,     // ID of the DIV to contain the map (false by default to be tolerant when no map defined)
        type: undefined, // Map type [search|modal|mini]
        XML: undefined, // Where to put any XML data describing map markers (for type search)
        coords: undefined, // Coordinates of the marker (for types modal and mini)
        /**
        * Initialise the map
        */
        init: function() {
            var copyrightCollection, pxLayer, mapType, hash, map, bounds, xml, latlng, pins, zoom, li, marker, icon, cLat, cLng;
            // Redefine this method of the maps API to unhilite markers and list items when the user closes an info window
            if (UNDEFINED !== typeof GInfoWindow) {
                GInfoWindow.prototype.onCloseButton = function(e) {
                    this.map.triggerEvent('infowindowclose');
                    this.hide();
                    TRAVELBUG.maps.unhilite();
                    Event.stop(e);
                };
            }
            if (UNDEFINED !== typeof GBrowserIsCompatible && GBrowserIsCompatible()) {
                if (UNDEFINED === typeof this.divId) {
                    throw 'PEBKAC: You must specify a div ID.';
                    return;
                }
                if (UNDEFINED === typeof this.type) {
                    throw 'PEBKAC: You must specify a map type.';
                    return;
                }
                if (UNDEFINED === typeof this.XML && UNDEFINED === typeof this.coords) {
                    throw 'PEBKAC: You must provide either XML or coordinates.';
                    return;
                }
                if ((SEARCH === this.type && UNDEFINED === typeof this.XML) || (SEARCH !== this.type && UNDEFINED === typeof this.coords)) {
                    throw 'PEBKAC: Map type does not match data provided.';
                    return;
                }
                if (false !== this.divId) {
                    _preloadImgs();
                    map = new GMap2($(this.divId));
                    bounds = new GLatLngBounds();
                    if (SEARCH === this.type) {
                        hash = TRAVELBUG.guiUtils.getHash();
                        map.setCenter(new GLatLng(0, 0));
                        map.addControl(new GLargeMapControl);
                        map.addControl(new GScaleControl);
                        xml = GXml.parse(this.XML);
                        pins = xml.documentElement.getElementsByTagName(PIN);
                        $A(pins).each(function(pinXML) {
                            var marker, point;
                            marker = _makeMarker(pinXML, map);
                            point = new GLatLng(marker.point.lat(), marker.point.lng());
                            bounds.extend(point);
                        });
                        _iconElements = _getIconElements();
                        cLat = (bounds.getNorthEast().lat() + bounds.getSouthWest().lat()) / 2;
                        cLng = (bounds.getNorthEast().lng() + bounds.getSouthWest().lng()) / 2;
                        latlng = new GLatLng(cLat, cLng);
                        // give the map a new center so we can correctly get the bounds zoom level
                        map.setCenter(latlng);
                        zoom = map.getBoundsZoomLevel(bounds);
                        if (zoom > DEFAULT_ZOOM) {
                            zoom = DEFAULT_ZOOM;
                        }
                        // give the map our final center + zoom
                        map.setCenter(latlng, zoom);
                        if (hash) {
                            // open the info window for the indicated location
                            marker = _getMarkerFromLocationId(hash);
                            if (UNDEFINED !== typeof marker) {
                                li = _getListItemFromMarker(marker);
                                _onMouseover(marker, li);
                                _onClick(marker, li);
                            }
                        }
                    } else {
                        if (MODAL === this.type) {
                            map.addControl(new GLargeMapControl());
                            map.addControl(new GScaleControl());
                        } else {
                            map.addControl(new GSmallMapControl());
                        }

                        latlng = new GLatLng(+this.coords.lat, +this.coords.lng);
                        icon = _makeIcon(_imgs.pinBlank);
                        map.setCenter(latlng, DEFAULT_ZOOM);
                        map.addOverlay(new GMarker(latlng, icon));
                    }
                } else {
                    // no search results
                }
            } else {
                // maps is down
            }
        },
        /**
        * Add the location to the user's shortlist
        * @param DOM element a          Anchor element containing the save to shortlist image
        * @param int         locationId Location ID to add to the shortlist
        */
        addToShortlist: function(a, locationId) {
            // disable clicking and hovers on the shortlist button
            a.firstChild.onmouseout = function() { return false; };
            a.onclick = function() { return false; };
            a.firstChild.src = this.getImgSaving();
            lowerId = locationId.toLowerCase();
            TRAVELBUG.shortlist.add(lowerId, 'M', 'TRAVELBUG.maps.updateBalloon(\'' + locationId + '\')', false); // true here means we'll open the info balloon if the user is bumped to the login page and redirected

        },
        /**
        * Update the info window, if it's still open
        * @param int locationId Location ID
        */
        updateBalloon: function(locationId) {
            var marker, rgxp, imgTag, img;
            marker = _getMarkerFromLocationId(locationId);
            if (UNDEFINED !== typeof marker) {
                // make it so subsequent clicks on this location will show as saved
                rgxp = /class\="tb-infowin-shortlist">.*<\/a>/;
                imgTag = 'class="tb-infowin-shortlist">' + _tmpls.shortlist;
                marker.html = marker.html.replace(rgxp, imgTag);
            }
            img = $('save-to-shortlist-' + locationId);
            if (null !== img) {
                img.onmouseover = function() { return false; };
                img.src = _imgs.savedToFavs;
            }
        },
        // public wrapper for unhiliting markers and list items
        unhilite: function() {
            _onMouseout({ id: false });
        },
        // Convenience methods to get image sources
        getImgSave: function() {
            return _imgs.saveToFavs;
        },
        getImgSaveHover: function() {
            return _imgs.saveToFavsHover;
        },
        getImgSaving: function() {
            return _imgs.savingToFavs;
        }
    }
})();

/**
 * Date manipulations (e.g., changing from a string to a Date object)
 */
TRAVELBUG.dates = (function () {
    // private variables
    var _dateFormat, _daysOfWeek, _months;

    _dateFormat   = new Template('#{year}-#{month}-#{day}');
    _daysOfWeek   = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
    _months       = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
    _daysInMonths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

    // public methods
    return {
        getDow: function (ymd) {
            var date;
            date = this.ymd2obj(ymd);
            return _daysOfWeek[date.getDay()];
        },
        getMonth: function (ymd) {
            var date;
            date = this.ymd2obj(ymd);
            return _months[date.getMonth()];
        },
        // based on: http://jszen.blogspot.com/2007/03/how-to-build-simple-calendar-with.html
        getDaysInMonth: function (m, y) {
            m = +m; // cast as int
            y = +y; // cast as int
            // if the month is February and it's a leap year, return 29
            // test for 1 === m first to short-circuit the more expensive tests
            if (1 === m && ((0 === y % 4 && 0 !== y % 100) || 0 === y % 400)) {
                return 29;
            } else {
                return _daysInMonths[m];
            }
        },
        isLeapYear: function (y) {
            if ((0 === y % 4 && 0 !== y % 100) || 0 === y % 400) {
                return true;
            } else {
                return false;
            }
        },
        isToday: function (ymd) {
            var now, nowYmd;
            now    = new Date();
            nowYmd = this.obj2ymd(now);
            return (nowYmd === ymd);
        },
        isWeekendDay: function (ymd) {
            var dow, date;
            date = this.ymd2obj(ymd);
            dow = date.getDay();
            return (0 === dow || 6 === dow);
        },
        ymd2obj: function (ymd) { // take a date in format YYYY-MM-DD and return a JavaScript date object
            var pieces;
            pieces = ymd.split('-');
            return new Date(pieces[0], pieces[1] - 1, pieces[2]);
        },
        obj2ymd: function (obj) { // take a JavaScript date object and return a string in format YYYY-MM-DD
            var year, month, day;
            if ('number' === typeof obj) {
                obj = new Date(obj);
            }
            year = obj.getFullYear();
            month = (obj.getMonth() + 1).toString();
            day = (obj.getDate()).toString();
            if (month.length < 2) {
                month = '0' + month;
            }
            if (day.length < 2) {
                day = '0' + day;
            }
            return _dateFormat.evaluate({year: year, month: month, day: day});
        }
    };
})();

/**
 * Manage the pop-up calendar and select lists that are part of the search form
 */
TRAVELBUG.dateSelect = (function () {
    // private variables
    var IN, OUT, DASH, OPTION, _ids, _calDiv, _tmpls;
    IN       = TRAVELBUG.constants.IN;
    OUT      = TRAVELBUG.constants.OUT;
    DASH     = TRAVELBUG.constants.DASH;
    OPTION   = TRAVELBUG.constants.OPTION;
    _ids     = undefined;
    _calDiv  = undefined;
    _tmpls   = {
        ymd : new Template('#{y}-#{m}-#{d}')
    };
    // private methods
    var _getDateParts, _getJsMonth, _getYearFromMonth, _rebuildDowSelect, _updateSelects, _updateValue, _appendCalDiv;
    /**
     * Return individual values from a YYYY-MM-DD formatted date string
     * @param string ymd YYYY-MM-DD formatted date string
     * @return object Hash of date values (e.g., {y: 2008, m: 4, d: 14})
     */
    _getDateParts = function (ymd) {
        var parts;
        parts = ymd.split(DASH);
        return {
            y: +parts[0],
            m: +parts[1],
            d: +parts[2]
        };
    };
    /**
     * Return the JavaScript representation of a given human-readable month value
     * @param int m Human-readable representation of a month (e.g., 12 for December)
     * @return int JavaScript representation of a month (e.g., 0 for January)
     */
    _getJsMonth = function (m) {
        return m - 1;
    };
    /**
     * Figure the year for a given month, relative to the current year
     * @param int m Human-readable representation of a month (e.g., 12 for December)
     * @return int Year in which the month falls, either this year or next
     */
    _getYearFromMonth = function (m) {
        var now, currentMonth, currentYear, returnYear;
        m   = _getJsMonth(m);
        now = new Date();
        currentMonth = now.getMonth();
        currentYear  = now.getFullYear();
        returnYear   = m < currentMonth ? 1 + currentYear : currentYear;
        return returnYear;
    };
    /**
     * Rebuild the days select list (including the day of the week) to reflect the number of days in the month
     * @param int y Year (e.g., 2008)
     * @param int m Human-readable representation of a month (e.g., 12 for December)
     * @param string inOut 'in' if an in date, 'out' if an out date
     */
    _rebuildDowSelect = function (y, m, inOut) {
        var numDays, selDay, ymd, dow;
        
        numDays = TRAVELBUG.dates.getDaysInMonth(_getJsMonth(m), y);
        selDay  = (IN === inOut)? $(_ids.sels.inDay): $(_ids.sels.outDay);
        
        // Since we are building a select list which includes the day of week
        // it is no longer enough to just remove or add day numbers depending on how
        // many days are in the month but we have to rebuild the entire list because
        // the day of week will change for each month
        
        while (selDay.options.length > 0) {
            selDay.remove(selDay.options.length - 1);
        }
        
        while (selDay.options.length < numDays) {
            ymd = TRAVELBUG.dates.obj2ymd(new Date(y, _getJsMonth(m), selDay.options.length + 1));
            dow = TRAVELBUG.dates.getDow(ymd);
            TRAVELBUG.formUtils.addSelectOption(selDay, dow + ' ' + (+(selDay.options.length + 1)), selDay.options.length + 1, false);
        }
    };
    /**
     * Update the select lists for a given date (including rebuilding the number of days, if necessary)
     * @param int y Year (e.g., 2008)
     * @param int m Human-readable representation of a month (e.g., 12 for December)
     * @param int d Day (e.g. 31)
     * @param string inOut 'in' if an in date, 'out' if an out date
     * @return string YYYY-MM-DD formatted date string (putting in 2009-02-31 will return 2009-02-28)
     */
    _updateSelects = function (y, m, d, inOut) {
        var ymd, selDay, selMon;
        _rebuildDowSelect(y, m, inOut);
        if (IN === inOut) {
            selDay = $(_ids.sels.inDay);
            selMon = $(_ids.sels.inMonth);
        } else {
            selDay = $(_ids.sels.outDay);
            selMon = $(_ids.sels.outMonth);
        }

        if (selDay.options.length < d) {
            selDay.selectedIndex = selDay.options.length - 1;
        } else {
            selDay.selectedIndex = d - 1;
        }
        
        $R(0, 11, false).each(function (n) {
            if (+selMon.options[n].value === m) {
                selMon.selectedIndex = n;
                throw $break;
            }
        });
        ymd = _tmpls.ymd.evaluate({y: y, m: m, d: selDay.options[selDay.selectedIndex].value});
        return ymd;
    };
    /**
     * Update the value of the hidden input responsible for conveying this date
     * @param string ymd YYYY-MM-DD formatted date string
     * @param string inOut 'in' if an in date, 'out' if an out date
     */
    _updateValue = function (ymd, inOut) {
        var input;
        input = (IN === inOut)? $(_ids.inputs.inValue): $(_ids.inputs.outValue);
        input.value = ymd;
    };
    /**
     * Update the out date when the in date changes
     * so that the out date becomes in date + 1 day
     * @param string ymd YYYY-MM-DD formatted date string
     * @param string inOut 'in' if an in date, 'out' if an out date
     */
    _updateOut = function (ymd, inOut) {
        var outYmd, outYmdParts, inYmd, inYmdParts, now, currentMonth, currentYear;
        
        // If the in date was changed then update the out date
        if (IN === inOut) {
            outYmd = TRAVELBUG.dates.obj2ymd((TRAVELBUG.dates.ymd2obj(ymd)).addDays(1));
            outYmdParts = _getDateParts(outYmd);
            
            now = new Date();
            currentMonth = now.getMonth() + 1;
            currentYear  = now.getFullYear();
            
            // If adding a day to the in date goes beyond the selectable days
            // on the calendar then move the in date back one day
            if (outYmdParts.m === currentMonth && outYmdParts.y > currentYear) {
                inYmd = TRAVELBUG.dates.obj2ymd((TRAVELBUG.dates.ymd2obj(ymd)).addDays(-1));
                inYmdParts = _getDateParts(inYmd);
                
                inYmd = _updateSelects(inYmdParts.y, inYmdParts.m, inYmdParts.d, IN);
                _updateValue(inYmd, IN);
                
                outYmd = ymd;
                outYmdParts = _getDateParts(outYmd);
            }
            
            outYmd = _updateSelects(outYmdParts.y, outYmdParts.m, outYmdParts.d, OUT);
            _updateValue(outYmd, OUT);
        }
    };
    /**
     * Append the calendar div to the document body so we can use Prototype's built-in positioning methods
     */
    _appendCalDiv = function () {
        var node, calDiv;
        node = document.createElement('DIV');
        node.id = _ids.calDiv;
        document.body.insertBefore(node, document.body.firstChild);
        calDiv  = $(_ids.calDiv);
        return calDiv;
    };
    // public variables and methods
    return {
        calendars: {
            inDate  : undefined,
            outDate : undefined
        },
        /**
         * Initialise the 2 pop-up calendars on the search form
         * @param object params Hash of values to set initial state of calendars (written to page by PHP)
         */
        init: function (params, homepage) {
            // 0    1  2
            // 2008-04-14
            // params.lastValidDate
            // params.inDate
            // params.outDate
            var offsetX, offsetY, today, oneDayAgo, inDate, outDate, lastValidInDate;
            _ids = params.ids;
            _calDiv = _appendCalDiv();
            offsetX = 0;
            offsetY = 0;
            if (homepage && TRAVELBUG.guiUtils.isIE) offsetX = 30;
            today     = TRAVELBUG.dates.obj2ymd((new Date()));
            oneDayAgo = TRAVELBUG.dates.obj2ymd((new Date()).addDays(-1));
            inDate    = _getDateParts(params.inDate);
            outDate   = _getDateParts(params.outDate);
            lastValidInDate = TRAVELBUG.dates.obj2ymd((TRAVELBUG.dates.ymd2obj(params.lastValidDate)).addDays(-1));
            // set up in date calendar
            this.calendars.inDate = new CalendarPopup(_ids.calDiv, offsetX, offsetY, homepage);
            this.calendars.inDate.addDisabledDates(null, oneDayAgo);
            this.calendars.inDate.addDisabledDates(lastValidInDate, null);
            this.calendars.inDate.setReturnFunction('TRAVELBUG.dateSelect.on.inClick');
            // set up out date calendar
            this.calendars.outDate = new CalendarPopup(_ids.calDiv, offsetX, offsetY, homepage);
            this.calendars.outDate.addDisabledDates(null, today);
            this.calendars.outDate.addDisabledDates(params.lastValidDate, null);
            this.calendars.outDate.setReturnFunction('TRAVELBUG.dateSelect.on.outClick');
            // set select list values
            this.on.inClick(inDate.y, inDate.m, inDate.d);
            this.on.outClick(outDate.y, outDate.m, outDate.d);
        },
        // handle user actions
        on: {
            inClick: function (y, m, d) {
                this.calendarClick(y, m, d, IN);
            },
            outClick: function (y, m, d) {
                this.calendarClick(y, m, d, OUT);
            },
            calendarClick: function (y, m, d, inOut) {
                var ymd;
                ymd = _updateSelects(y, m, d, inOut);
                _updateValue(ymd, inOut);
                _updateOut(ymd, inOut);
            },
            selectChange: function (inOut) {
                var y, m, d, ymd;
                if (IN === inOut) {
                    m = +$F(_ids.sels.inMonth);
                    d = +$F(_ids.sels.inDay);
                } else {
                    m = +$F(_ids.sels.outMonth);
                    d = +$F(_ids.sels.outDay);
                }
                y   = _getYearFromMonth(m);
                ymd = _updateSelects(y, m, d, inOut);
                _updateValue(ymd, inOut);
                _updateOut(ymd, inOut);
            },
            /**
             * Return true or false depending on whether we have a valid date range
             * @param object ids Hash of element IDs of the form elements containing the in and out date values
             */
            submit: function (ids) {
                var now, nowVal, inVal, outVal;
                now    = new Date();
                nowVal = (new Date(now.getFullYear(), now.getMonth(), now.getDate())).getTime(); // compare apples to apples, ie., 2008-04-10 00:00:00
                inVal  = (TRAVELBUG.dates.ymd2obj($F(_ids.inputs.inValue))).getTime();
                outVal = (TRAVELBUG.dates.ymd2obj($F(_ids.inputs.outValue))).getTime();
                if (inVal < nowVal) {
                    alert('Your check in date must be today or later.');
                    return false;
                } else if (outVal <= inVal) {
                    alert('Your check out date must be after your check in date.');
                    return false;
                } else {
                    return true;
                }
            }
        },
        // apply iframe fix to the calendar div
        // this is public so it can be called from calendar-popup.js and in tbtools.js so it has access to _calDiv
        calFixIE: function () {
            if (TRAVELBUG.guiUtils.isIE6) {
                var width, height, innerHTML;
                width     = _calDiv.getWidth();
                height    = _calDiv.getHeight();
                innerHTML = '<iframe id=\"cal-iframe\" src=\"about:blank\" scrolling=\"no\" frameborder=\"0\" style=\"width: ' + width + 'px;height: ' + height + 'px;\"><\/iframe>' + $(_calDiv).innerHTML;
                _calDiv.update(innerHTML);
            }
        }
    };
})();

/**
 * Manage regions / districts drop-down menus (requires TRAVELBUG.districts.districtsAndRegions to be set inline on the page)
 */
TRAVELBUG.districts = (function() {
    // public variables and methods
    return {
        districtsAndRegions: undefined,
        update: function() {
            var region, selObj;
            region = $F('region');
            if ('none' === region) {
                region = '';
                $('region').selectedIndex = 0;
            }
            selObj = $('district');
            // blow away current district options
            selObj.options.length = 0;
            TRAVELBUG.guiUtils.addOption(selObj, '', 'Any district');
            TRAVELBUG.guiUtils.addOption(selObj, 'none', '');
            if (region === 'none' || region === '') {
                selObj.disabled = true;
            } else {
                selObj.disabled = false;
                // add new district options
                var districts = TRAVELBUG.districts.districtsAndRegions[region];
                for (var i = 0; i < districts.length; i++) {
                    TRAVELBUG.guiUtils.addOption(selObj, districts[i], districts[i]);
                }
            }
        }
    };
})();

/**
 * Monitor AJAX requests for timeouts
 * Based on the script here: http://codejanitor.com/wp/2006/03/23/ajax-timeouts-with-prototype/
 *
 * Usage: If an AJAX call takes more than the designated amount of time to return, we call the onFailure
 *        method (if it exists), passing an error code to the function.
 *
 */

var xhr = {
    errorCode: 'timeout',
    callInProgress: function (xmlhttp) {
        switch (xmlhttp.readyState) {
            case 1: case 2: case 3:
                return true;
            // Case 4 and 0
            default:
                return false;
        }
    }
};

// Register global responders that will occur on all AJAX requests
Ajax.Responders.register({
    onCreate: function (request) {
        request.timeoutId = window.setTimeout(function () {
            // If we have hit the timeout and the AJAX request is active, abort it and let the user know
            if (xhr.callInProgress(request.transport)) {
                var parameters = request.options.parameters;
                request.transport.abort();
                // Run the onFailure method if we set one up when creating the AJAX object
                if (request.options.onFailure) {
                    request.options.onFailure(request.transport, xhr.errorCode, parameters);
                }
            }
        },
        // 10 seconds
        10000);
    },
    onComplete: function (request) {
        // Clear the timeout, the request completed ok
        window.clearTimeout(request.timeoutId);
    }
});

/**
 * Debugging functions
 */
var DEBUG = (function () {
    var _defaultHTML, _divCSS, _init, _startTime, _elapsedTime;
    _defaultHTML = '<a href="#" onclick="DEBUG.clear();return false;">Clear</a><hr/>';
    _divCSS = {
        position   : 'absolute',
        top        : '30px',
        right      : '10px',
        width      : '400px',
        border     : 'solid 1px black',
        background : '#ccc',
        padding    : '10px',
        font       : '11px/1.2em verdana',
        textAlign  : 'left',
        zIndex     : 9999
    };
    _init = function () {
        var debugDiv;
        debugDiv = document.getElementById('debug');
        if (null === debugDiv) {
            debugDiv    = document.createElement('DIV');
            debugDiv.id = 'debug';
            for (var prop in _divCSS) {
                debugDiv.style[prop] = _divCSS[prop];
            }
            document.body.insertBefore(debugDiv, document.body.firstChild);
            DEBUG.clear();
        }
    };
    return {
        write: function (s) {
            _init();
            document.getElementById('debug').innerHTML += s + '<br />';
        },
        reveal: function (obj, showFunctions) {
            for (prop in obj) {
                if (showFunctions || 'function' !== typeof obj[prop]) {
                    this.write(prop + ': ' + obj[prop]);
                }
            }
        },
        startTimer: function () {
            _startTime = (new Date()).getTime();
        },
        markTime: function () {
            _elapsedTime = ((new Date()).getTime()) - _startTime;
        },
        // return the number of milliseconds elapsed between the last startTimer() and markTime() calls
        getElapsedTime: function () {
            return (_elapsedTime > 0)? _elapsedTime : 0;
        },
        clear: function () {
            document.getElementById('debug').innerHTML = _defaultHTML;
        }
    }
})();

