Source: src/bulletin_board_api.js

/**
 * @file Bulletin Board API
 * @author Andrew Sayers
 */

/**
 * @summary Generic class for any type of bulletin board
 * @constructor
 * @abstract
 *
 * @example
 * var bb = new ChildOfBulletinBoard({
 *     config: { ... },
 *     origin: 'http://www.example.com', // default: current site
 *     doc: my_document // default: document
 * });
 *
 * @description At the time of writing, the only configuration values are:
 * + "unPMable user groups" (an array like [ 'group name', ... ] defining groups that cannot accept PMs)
 * + "default user group" (a string defining the group most users belong to)
 */
function BulletinBoard(args) {
    this._config = $.extend( { 'unPMable user groups': [], 'default user group': '' }, args ? args.config : {} );
    this._origin = args ? args.origin : '';
    this.url_for = {};
    this.doc = $( ( args || {} ).doc || document );
}
BulletinBoard.prototype = Object.create(null, {

    _config: { writable: true, configurable: false },
    _origin: { writable: true, configurable: false },
    doc    : { writable: true, configurable: false },
    // construct product-specific URLs - concrete implementations should add functions here
    url_for: { writable: true, configurable: false },

    // number of replies per page (not counting the first post on forums that treat the first post specially):
    default_reply_count: { writable: false, configurable: false, value: 200 },

});

/*
 * GENERIC UTILITY FUNCTIONS
 */

/**
 * @summary convert a relative URL to use the correct origin
 * @param {string} url URL to translate
 * @return {string} absolute URL
 * @description BulletinBoards can have an origin other than the site we're currently on
 * (so we can e.g. browse as one user and post as another)
 */
BulletinBoard.prototype.fix_url = function(url) {
    if ( !this._origin ) return url;
    if ( url.search( /^[a-z]*:/ ) == 0 ) return                 url;   // http://www.example.com/foo.html
    if ( url.search( /^\// )      == 0 ) return this._origin +  url;   // /foo.html
    return this._origin + location.pathname.replace( /[^\/]*$/, url ); // foo.html
}

/**
 * @summary add or change configuration values
 * @param {Object} config new configuration values
 * @return (updated) configuration
 */
BulletinBoard.prototype.config = function( config ) {
    return $.extend( this._config, config );
}

/**
 * @summary Convenience function to construct a URL
 *
 * @example
 * // returns "/example.html?f=qux&b=default+value"
 * bb.build_url(
 *     '/example.html',
 *     [
 *         { key: 'foo', param: 'f' },
 *         { key: 'bar', param: 'b', default: 'default value' },
 *         { key: 'baz', param: 'bb' },
 *     ],
 *     { foo: 'qux' }
 * );
 *
 * @param {string}                 url base URL (e.g. "http://www.example.com/example.html")
 * @param {Array.<Object>}         valid_args arguments that could be added to this URL
 * @param {Object.<string,string>} args arguments to add to the URL
 * @param {string=}                hash string added after the '#'
 * @protected
 */
BulletinBoard.prototype.build_url = function( url, valid_args, args, hash ) {
    var connector = '?';
    if ( valid_args ) {
        if ( !args ) args = {};
        valid_args.forEach(function(arg) {
            if ( args.hasOwnProperty(arg.key) || arg.hasOwnProperty('default') ) {
                var value = args.hasOwnProperty(arg.key) ? args[arg.key] : arg['default'];
                if ( arg.map ) value = arg.map[value] || value;
                url += connector + encodeURIComponent(arg.param) + '=' + encodeURIComponent(value);
                connector = '&';
            }
        });
    }
    if ( hash ) url += '#' + hash;
    return this.fix_url(url);
}

/**
 * @summary Sane wrapper around $.when()
 * @param {Array.<jQuery.Promise>} promises array of promises to wait for
 * @return {jQuery.Promise} return values from each promise
 *
 * @description $.when() has several problems:
 * - it wants a variadic list of arguments
 * - it has a different return syntax when passed a single item than many
 * - does not notify on completion of individual promises
 *
 * This workalike solves those issues.
 *
 */
BulletinBoard.prototype.when = function(promises) {
    var promises_completed = 0;
    var ret = new Array(promises.length);
    var dfd = $.Deferred();
    promises.forEach(function(promise, index) {
        promise.then(
            function() {
                if ( arguments.length == 1 )
                    ret[index] = arguments[0];
                else
                    ret[index] = Array.prototype.slice.call( arguments, 0 );
                if ( ++promises_completed == promises.length )
                    dfd.resolve(ret);
                else if ( promises_completed < promises.length )
                    dfd.notify( promises_completed, promises.length );
            },
            function() {
                dfd.reject();
                promises_completed = promises.length + 1;
            }
        );
    });
    return dfd.promise();
}

/*
 * BULLETIN-BOARD-NEUTRAL UTILITY FUNCTIONS
 */

/**
 * @summary Connect to the server and get some very basic information
 * @protected
 * @param {...Object} var_args passed to $.get()
 * @return {jQuery.Promise}
 * @description the return value contains 'results ('success' or 'fail'),
 * 'duration' (milliseconds taken for the round trip), and 'offset'
 * (number of milliseconds the server is ahead of the client,
 * which can be negative)
 */
BulletinBoard.prototype.ping = function() {

    var start_time = new Date();

    function success(html, status, jqXHR) {
        var end_time = new Date().getTime();
        return {
            result  : 'success',
            duration:                                                                       end_time - start_time.getTime(),
            // calculate the time offset between us and the server, assuming the server time was generated exactly halfway through the request:
            offset  : new Date( jqXHR.getResponseHeader('Date') ).getTime() - Math.floor( ( end_time + start_time.getTime() ) / 2 )
        };
    }

    // the actual page isn't important, but robots.txt seems like an innocuous enough choice:
    return $.ajax({
        url : this.fix_url( '/robots.txt' ),
        xhr : function() { return new BabelExt.XMLHttpRequest() },
        type: 'OPTIONS'
    }).then( success, function(jqXHR) {
        var dfd = jQuery.Deferred();
        if ( jqXHR.statusCode() == 404 ) {
            dfd.resolve(success(null,null,jqXHR));
        } else {
            dfd.resolve({ result: 'fail', duration: new Date().getTime() - start_time.getTime() });
        }
        return dfd.promise();
    });

}

/**
 * @summary Send a message to the server as an AJAX request
 * @protected
 * @param {string} url  URL to get
 * @param {Object} data parameters
 * @return {jQuery.Promise}
 */
BulletinBoard.prototype.get = function( url, data ) {

    var bb = this;

    return $.ajax({
        url : this.fix_url(url),
        xhr : function() { return new BabelExt.XMLHttpRequest() },
        type: 'GET',
        data: data
    }).then(
        function(reply, status, jqXHR) {
            var err = bb.detect_post_error( reply );
            if ( err !== null ) {
                debug_log.log( "Couldn't load page", err );
                return $.Deferred().reject().promise();
            } else {
                return $.Deferred().resolve( reply, status, jqXHR ).promise();
            }
        },
        debug_log.log
    );

}

/**
 * @summary Send a message to the server as either an AJAX request or a form
 * @protected
 * @param {string}  url URL to send to
 * @param {Object}  data parameters to send
 * @param {boolean} use_form send a form requset instead of an AJAX request
 * @return {jQuery.Promise}
 */
BulletinBoard.prototype.post = function( url, data, use_form ) {

    var bb = this;

    var url = this.fix_url(url);

    return this._add_standard_data(data).then(function(data) {
        if ( use_form ) {
            var form = $('<form method="post"></form>')
                .appendTo('body')
                .attr( 'action', url );
            Object.keys(data).forEach(function(key) {
                if ( typeof(data[key]) != 'undefined' )
                    $('<input type="hidden">').appendTo(form).attr( 'name', key ).val( data[key] );
            });
            form.submit();
        } else {
            return $.ajax({
                type: "POST",
                url : url,
                xhr : function() { return new BabelExt.XMLHttpRequest() },
                data: data
            }).then(
                function(reply, status, jqXHR) {
                    var err = bb.detect_post_error( reply );
                    if ( err !== null ) {
                        debug_log.log( "Couldn't load page", url, err );
                        alert( "Couldn't load page " + url + "\n\nError:\n" + err );
                        return jQuery.Deferred().reject(err).promise();
                    } else {
                        return $.Deferred().resolve( reply, status, jqXHR ).promise();
                    }
                },
                debug_log.log
            );
        }
    });

}

/**
 * @summary Add parameters required for standard POST requests
 * @protected
 * @abstract
 * @param {Object} data parameters passed to jQuery
 * @return {jQuery.Promise} deferred object that will return when all necessary parameters have been found
 */
BulletinBoard.prototype._add_standard_data = function(data) {}

/**
 * @summary Get the current and maximum page number
 * @abstract
 * @param {string|jQuery|HTMLDocument} doc document to get posts for
 * @return {Array.<number>} current and maximum page numbers, number of posts on the page
 */
BulletinBoard.prototype.get_pages = function(doc) {}

/**
 * @summary Get all documents on the specified page
 * @abstract
 * @param {string|jQuery|HTMLDocument} doc document to get posts for
 * @return {jQuery} list of posts
 */
BulletinBoard.prototype.get_posts = function(doc) {}

/**
 * @summary Map a list of elements returned by get_posts() to data about the posts they represent
 * @abstract
 * @param {jQuery} posts list of post elements
 * @return {Array.<Object>} list of hashes describing each post
 */
BulletinBoard.prototype.process_posts = function(posts) {}

/**
 * @summary Return the error string contained in a reply to post(), or null on success
 * @protected
 * @abstract
 * @param {HTMLDocumentObject|XMLDocumentObject|string} reply
 * @param {string|null}
 */
BulletinBoard.prototype.detect_post_error = function(reply) {}

/*
 * FUNCTIONS DIRECTLY USEFUL TO CONSUMERS OF THIS API
 */

/**
 * @summary Map BBCode to a list of (non-nested) quotes
 * @param {string} text BBCode to parse
 * @return {Array.Object}
 *
 * @description Note: supports vBCode's [quote=author;post_id] syntax
 */
BulletinBoard.prototype.quotes_process = function(text) {
    var ret = [], regex = /\[quote="?([^\n=";\]]+)(?:;([^\n=";\]]*))?"?\]|\[\/quote\]/gi, start=0, depth=0, author, post_id, result;
    while ( (result = regex.exec(text)) ) {
        if ( result[0].toLowerCase() == '[/quote]' ) {
            if ( !--depth ) ret.push( { author: author, post_id: parseInt( post_id, 10 ), text: text.substr( start, result.index - start ) } );
        } else {
            if ( !depth++ ) {
                start = result.index + result[0].length;
                author = result[1];
                post_id = result[2];
            }
        }
    }
    return ret;
}

/**
 * @summary Get information about posts from many pages in a thread
 *
 * @description We need to get a single page first, to work out the
 * number of pages in the thread.  Passing in that first page will
 * make the function return faster, and will cause only pages after
 * that one to be loaded.
 *
 * @param {number} thread_id ID of thread to get posts for
 * @param {string|jQuery|HTMLDocument} doc document to get posts for
 * @param {boolean} skip_images convert all images to <img> - significantly improves performance
 * @return {jQuery.Promise} Deferred object that will return when all pages have loaded
 */
BulletinBoard.prototype.thread_posts = function( thread_id, first_page, skip_images ) {

    var bb = this;

    function get_later_pages(html) {
        if ( typeof(html) == 'string' ) {
            if ( skip_images ) html = html.replace(/<img[^>]*>/ig, '<img>');
            html = $(html);
        }

        var posts = bb.process_posts(bb.get_posts(html));

        if ( !posts.length ) {
            return $.Deferred().reject('no posts found').promise();
        }

        var more_pages = [];
        var pages = bb.get_pages(html);
        for ( var n=pages.current+1; n<=pages.total; ++n )
            more_pages.push(
                bb.get(bb.url_for.thread_show({ thread_id: thread_id, page_no: n }))
                    .then(function(html) {
                        if ( skip_images ) html = html.replace(/<img[^>]*>/ig, '<img>');
                        return bb.process_posts(bb.get_posts(html));
                    })
            );

        if ( more_pages.length ) {
            return bb.when(more_pages).then(
                function(more_posts) { more_posts.unshift(posts); return Array.concat.apply( [], more_posts ) },
                function(          ) { return 'Failed to load some later pages in ' + bb.url_for.thread_show({ thread_id: thread_id }) }
            );
        } else {
            return $.Deferred().resolve( posts ).promise();
        }
    }

    if ( first_page ) {

        if ( typeof(first_page) == 'string' ) {
            if ( skip_images ) first_page = first_page.replace(/<img[^>]*>/ig, '<img>');
            first_page = $(first_page);
        }

        var pages = bb.get_pages(first_page);
        if ( pages.current == pages.total ) {
            return new $.Deferred()
                .resolve( bb.process_posts(bb.get_posts(first_page)) )
                .promise()
            ;
        } else if ( pages.replies_on_page == this.default_reply_count )
            return get_later_pages(first_page);
        else {

            var first_post_index = (pages.current-1) * pages.replies_on_page;

            var current_page = Math.ceil( pages.current * pages.replies_on_page / this.default_reply_count );
            var   final_page = Math.ceil( pages.total   * pages.replies_on_page / this.default_reply_count );

            // number of posts to trim from the first downloaded page, to make it look as if we started loading on this page:
            var offset = ( pages.replies_on_page * (pages.current-1) ) - ( this.default_reply_count * (current_page-1) );
            var pages = [
                bb.get(bb.url_for.thread_show({ thread_id: thread_id, page_no: current_page }))
                    .then(function(html) {
                        if ( skip_images ) html = html.replace(/<img[^>]*>/ig, '<img>');
                        var posts = bb.process_posts(bb.get_posts(html));
                        posts.splice(0, offset);
                        return posts;
                    })
            ];

            for ( var n=current_page+1; n<=final_page; ++n )
                pages.push(
                    bb.get(bb.url_for.thread_show({ thread_id: thread_id, page_no: n }))
                        .then(function(html) {
                            if ( skip_images ) html = html.replace(/<img[^>]*>/ig, '<img>');
                            return bb.process_posts(bb.get_posts(html));
                        })
                );

            return bb.when(pages).then(
                function(posts) { return Array.concat.apply( [], posts ) },
                function(     ) { return 'Failed to load some pages in ' + bb.url_for.thread_show({ thread_id: thread_id }) }
            );
        }

    } else {

        return this.get( bb.url_for.thread_show({ thread_id: thread_id }) )
            .then(get_later_pages, function() {
                debug_log.log('Failed to load thread ' + bb.url_for.thread_show({ thread_id: thread_id }));
                return $.Deferred()
                    .reject('Failed to load thread ' + bb.url_for.thread_show({ thread_id: thread_id }) )
                    .promise()
                ;
            });

    }

}

/**
 * @summary get information about the specified account and suspected duplicate accounts
 * @param {Number} user_id user ID
 * @return {jQuery.Promise} promise
 * @example
 * bb.get_duplicates(1234)
 *     .progress( completed, total )
 *     .then(function(user) {
 *         console.log( user.suspiciousness, user.suspected_duplicates ); // see user_moderation_info() for details on users
 *     });
 */
BulletinBoard.prototype.user_duplicates = function(user_id) {

    var bb = this, dfd = $.Deferred();

    $.when(
        bb.user_info           (           user_id  ),
        bb.user_moderation_info(           user_id  ),
        bb.user_overlapping    ({ user_id: user_id })
    ).then(function(info, mod_info, users) {

        if ( !mod_info ) {
            dfd.resolve();
            return;
        }

        var completed_promises = 0;

        var promises = users.map(function(user) {
            return bb.user_info(user.user_id).then(function(info) {
                dfd.notify( ++completed_promises, users.length*2 );
                user.suspiciousness = (
                    ( info.is_banned        && 8 ) | // same IP as a currently-banned user - DODGEY!
                    ( info.infraction_count && 4 ) | // same IP as an infracted user - probably dodgey
                    ( info.   warning_count && 2 ) | // same IP as a user with a warning - might well be dodgey
                    1     // same IP as another user - could be dodgey
                );
                user.info = info;
            });
        }).concat(
            users.map(function(user) {
                return bb.user_moderation_info(user.user_id).then(function(info) {
                    dfd.notify( ++completed_promises, users.length*2 );
                    user.moderation_info = info;
                });
            })
        );

        return $.when.apply( $, promises ).then(function() {
            var user_info = {
                           info:     info,
                moderation_info: mod_info,

                user_id : mod_info.user_id,
                username: mod_info.username,
                email   : mod_info.email
            };
            if ( users.length ) {
                users.sort(function(a,b) { return b.suspiciousness - a.suspiciousness || a.username.localeCompare(b.username) });
                user_info.suspiciousness = users[0].suspiciousness;
            } else {
                user_info.suspiciousness = 0;
            }
            user_info.suspected_duplicates = users;
            dfd.resolve( user_info );
        });

    });

    return dfd.promise();

}


/**
 * @summary Escape an object in a way that's safe to put in a forum post
 * @param {string}   name   object name
 * @param {Object}   object object to stringify
 * @param {function} cmp    comparison function
 * @return {string} text to include in a post
 */
BulletinBoard.prototype.stringify = function(name, object, cmp) {
    return '[code]/* BEGIN DATA BLOCK: ' + name.toUpperCase() + ' */\n' + stringify( object, cmp ) + '\n/* END DATA BLOCK: ' + name.toUpperCase() + ' */[/code]'
}

BulletinBoard.prototype._parse = function(name, text, before, after) {
    var ret = null;
    text.replace(
        new RegExp( before + '/\\* BEGIN DATA BLOCK: ' + name.toUpperCase() + ' \\*/\\s*((?:.|\\n)*?)\\s*\\/\\* END DATA BLOCK: ' + name.toUpperCase() + ' \\*/' + after ),
        function( match, json ) { ret = JSON.parse(json) }
    );
    return ret;
}

/**
 * @summary Retrieve a string previously encoded with .stringify()
 * @param {string} name object name
 * @param {string} text post text
 * @return {Object} parsed object
 */
BulletinBoard.prototype.parse = function(name, text) {
    return this._parse( name, text, '\\[code\\]', '\\[/code\\]' );
}

/**
 * @summary Retrieve a string previously encoded with .stringify()
 * @param {string} name object name
 * @param {Object} post post data returned from process_posts()
 * @return {Object} parsed object
 */
BulletinBoard.prototype.parse_post = function(name, post) {/*
    var bb = this;
    return post.message_element.find( '...').get().reduce(function(prev,post) { return bb._parse( name, post.textContent, '^', '$' ) || prev });
*/}




/**
 * @summary vBulletin API
 * @extends BulletinBoard
 * @constructor
 */
function VBulletin(args) {
    BulletinBoard.call(this, args);

    var bb = this;

    var default_dateline = new Date().getTime();

    if ( bb.session_id ) {
        setInterval(
            function() {
                // can't reset the token without cookies - just ping the page and hope for the best
                bb.get( '/' ).then(function() {
                    BabelExt.memoryStorage.set( 'VBulletin login ' + bb._origin + ' ' + bb.default_user, JSON.stringify({
                        creation_time : new Date().getTime(),
                        session_id    : bb.session_id,
                        security_token: bb.security_token
                    }))
                });
            }, 1000*60*30 );
    } else {
        $(function() {
            this.security_token = bb._get_token();
            setInterval(function() { bb.check_login(true) }, 1000*60 ); // update security credentials every 30 minutes
        });
    }

    $.extend(
        this.url_for,
        {
            activity: function() { return bb.build_url( '/activity.php' ) },

            forum_show: function(args) { return bb.build_url(
                '/forumdisplay.php',
                [
                    { key: 'forum_id', param: 'f' }
                ],
                args
            )},

            login: function() { return bb.build_url(
                '/login.php',
                [
                    { key: 'do', param: 'do', default: 'login' }
                ]
            )},

            folder_show: function(args) { return bb.build_url(
                '/private.php',
                [
                    { key: 'folder_id', param: 'folderid' },
                ],
                args
            )},

            moderation_inline: function() { return bb.build_url( '/inlinemod.php' ) },
            moderation_posts : function() { return bb.build_url( '/modcp/moderate.php?do=posts' ) },
            moderation_user  : function() { return bb.build_url( '/modcp/user.php' ) },

            // This is a fake URL - you will need to call redirect_modcp_ipsearch() on the page:
            moderation_ipsearch: function(args) { return bb.build_url(
                '/modcp/user.php',
                [
                    { key: 'action'    , param: 'do', default: 'doips' },
                    { key: 'ip_address', param: 'ipaddress' },
                    { key: 'username'  , param: 'username' },
                    { key: 'user_id'   , param: 'userid' },
                    { key: 'depth'     , param: 'depth' }
                ],
                args
            )},

            infraction: function(args) { return bb.build_url(
                '/infraction.php',
                [
                    { key: 'user_id', param: 'u' },
                    { key: 'post_id', param: 'p' },
                    { key: 'action' , param: 'do', 'default': 'view' },
                ],
                args
            )},

            user_notes: function(args) { return bb.build_url(
                '/usernote.php',
                [
                    { key: 'user_id', param: 'u' }
                ],
                args
            )},

            user_show: function(args) { return bb.build_url(
                '/member.php',
                [
                    { key: 'user_id', param: 'u' }
                ],
                args
            )},
            user_avatar: function(args) { return bb.build_url(
                '/image.php',
                [
                    { key: 'user_id', param: 'u' },
                    { key: 'date'   , param: 'dateline', 'default': default_dateline }
                ],
                args
            )},

            users_show: function(args) { return bb.build_url(
                '/memberlist.php',
                [
                    { key: 'order'   , param: 'order', 'default': 'desc' },
                    { key: 'sort'    , param: 'sort' , 'default': 'joindate' },
                    { key: 'per_page', param: 'pp'   , 'default': '100' },
                    { key: 'page_no' , param: 'page' }
                ],
                args
            )},

            search: function() { return bb.build_url( '/search.php' ) },

            thread_edit: function(args) { return bb.build_url(
                '/postings.php',
                [
                    { key: 'action'   , param: 'do' },
                    { key: 'thread_id', param: 't' }
                ],
                args
            )},
            thread_show: function(args) {
                if ( (args.page_no||0) == 1 ) delete args.page_no; // VBulletin will redirect us if passed this
                return bb.build_url(
                '/showthread.php',
                [
                    { key: 'thread_id'      , param: 't' },
                    { key: 'posts_per_page' , param: 'pp', default: VBulletin.prototype.default_reply_count },
                    { key: 'post_id'        , param: 'p' },
                    { key: 'page_no'        , param: 'page' },
                    { key: 'show_if_deleted', param: 'viewfull', map: { true: 1, false: '' } },
                    { key: 'goto'           , param: 'goto' }, // usually 'newpost'
                ],
                args,
                args.post_id ? 'post' + args.post_id : undefined // hash
            )},
            thread_user_posts: function(args) { return bb.build_url(
                '/search.php',
                [
                    { key: 'action'        , param: 'do', default: 'finduser' },
                    { key: 'content_type'  , param: 'contenttype', default: 'vBForum_Post' },
                    { key: 'posts_show'    , param: 'showposts', default: 1 },
                    { key: 'user_id'       , param: 'userid' },
                    { key: 'thread_id'     , param: 'searchthreadid' },
                    { key: 'posts_per_page', param: 'pp', default: VBulletin.prototype.default_reply_count },
                ],
                args
            )},

            post_edit: function() { return bb.build_url( '/editpost.php' ) },
            post_show: function(args) { return bb.build_url(
                '/showthread.php',
                [
                    { key: 'thread_id'      , param: 't' },
                    { key: 'post_id'        , param: 'p' },
                    { key: 'show_if_deleted', param: 'viewfull', map: { true: 1, false: '' } },
                ],
                args,
                args.post_id ? 'post' + args.post_id : undefined // hash
            )},

        }
    );

}
VBulletin.prototype.constructor = VBulletin;
VBulletin.prototype = Object.create(BulletinBoard.prototype, {

    // only used if "origin" is set:
    default_user  : { writable: true, configurable: false },
    session_id    : { writable: true, configurable: false },
    security_token: { writable: true, configurable: false, default: '' },

    /*
     * I suspect Chrome initialises all tabs when the browser opens,
     * causing a race condition when they all reset sessions at the same time:
     */
    session_timeout: { writable: false, configurable: false, default: Math.floor( ( Math.random()*10 + 20 ) * 60 * 1000 ) },

    default_reply_count: { writable: false, configurable: false, value: 200 },

    standard_post_data: {
        writable: false,
        configurable: false,
        value: {
            parseurl: 1,
            //signature: 1, // signatures distract from the objectivity of the communication
            wysiwyg: 0
        }
    },

    redirect_duration: { // redirects are shown for one week by default
        writable: false,
        configurable: false,
        value: { period: 1, frame: 'w' }
    },

    _forum_threads_callback_initialised: {
        writable: true,
        configurable: false,
        value: 0
    },

    _user_ips_data: {
        writable: true,
        configurable: false,
        value: 0
    }

});

/*
 * UTILITY FUNCTIONS
 */

/**
 * @summary Check we're logged in, and update login credentials
 * @param {boolean=} lazy only updating credentials if the session_timeout has elapsed
 * @return {jQuery.Promise} promise that returns when credential update succeeds or fails
 * @description you should only need set "lazy" for code that runs on a timer, potentially in many tabs.
 * This stops people with many tabs open from spamming the server too often.
 */
VBulletin.prototype.check_login = function(lazy) {

    var bb = this;

    if ( bb._origin ) {

        var dfd = $.Deferred();
        BabelExt.memoryStorage.get( 'VBulletin login ' + bb._origin + ' ' + bb.default_user, function(data) {
            if ( data.value ) {
                var credentials = JSON.parse(data.value);
                if ( credentials.creation_time + 60*60*1000 > new Date().getTime() ) { // update settings if the session is still valid (sessions last for one hour)
                    bb.session_id     = credentials.session_id;
                    bb.security_token = credentials.security_token;
                }
                if ( !lazy || credentials.creation_time + bb.session_timeout < new Date().getTime() ) {
                    bb.post( '/ajax.php?do=securitytoken', { do: 'securitytoken' } ).then(function(xml) {
                        var securitytoken = xml.getElementsByTagName('securitytoken');
                        if ( !securitytoken.length    ) return dfd.reject('No security token on ' + bb._origin + ' (this should not happen)').promise();
                        securitytoken = securitytoken[0].textContent;
                        if ( securitytoken == 'guest' ) return dfd.reject('You have been logged out from ' + bb._origin                     ).promise();
                        BabelExt.memoryStorage.set( 'VBulletin login ' + bb._origin + ' ' + bb.default_user, JSON.stringify({
                            creation_time : new Date().getTime(),
                            security_token: bb.security_token = securitytoken
                        }));
                        dfd.resolve();
                    });
                } else {
                    dfd.resolve();
                }
            } else {
                dfd.reject('No login information for ' + bb._origin);
            }
        });
        return dfd.promise();

    } else if ( bb._get_token() == this.security_token ) { // skip this if the token was already updated

        return bb.post( '/ajax.php?do=securitytoken', { do: 'securitytoken' } ).then(function(xml) {
            var securitytoken = xml.getElementsByTagName('securitytoken');
            if ( !securitytoken.length    ) return $.Deferred().reject('No security token (this should not happen)').promise();
            securitytoken = securitytoken[0].textContent;
            if ( securitytoken == 'guest' ) return $.Deferred().reject('You have been logged out'                  ).promise();
            $('input[name="securitytoken"]').val(this.security_token = securitytoken);
        });

    } else {

        return $.Deferred().resolve();

    }

}

VBulletin.prototype.fix_url = function(url) {
    url = BulletinBoard.prototype.fix_url.call(this, url);
    if ( this.session_id )
        url += ( ( url.search(/\?/) == -1 ) ? '?s=' : '&s=' ) + this.session_id;
    return url;
}

VBulletin.prototype.get_posts = function(doc) { return $( doc || this.doc ).find('#posts').children().get() }

VBulletin.prototype.get_pages = function(doc) {
    doc = $( doc || this.doc );
    var ret = { current: 1, total: 1 };
    ( doc.find('.pagination a').first().text() || '' ).replace( /Page ([0-9]+) of ([0-9]+)/, function(match, current, total) {
        ret = { current: parseInt( current, 10 ), total: parseInt( total , 10 ) };
    });
    ret.replies_on_page = doc.find('#posts > li:not(.postbitdeleted)').length;
    return ret;
}

// shared processing between posts and user notes - note this will not usually be called as a member function:
VBulletin.prototype._process_post_note = function(element, type, parse_date) {

    // this is a surprisingly tight loop when downloading a large thread (up to 10,000 posts in quick succession),
    // so we mostly avoid jQuery and go straight to DOM accessors

    var username = element.getElementsByClassName('username')[0];
    var title    = element.getElementsByClassName('title'   )[0];
    var date     = element.getElementsByClassName('date'    )[0];

    var ret = {

        container_element: element,

        date             : date.textContent,
        date_element     : date,
        date_object      : parse_date ? parse_date( date.textContent ) : undefined,

        title            : title ? title.textContent.replace(/^\s*/, '').replace(/\s*$/, '') : '',

        username         : username.textContent,
        user_id          : parseInt( ( username.getAttribute('href') || '             guest' ).substr(13), 10 ),

        is_deleted       : element.className.search( /\bpostbitdeleted\b/ ) != -1,
        is_moderated     : !!element.getElementsByClassName('moderated').length,
        is_ignored       : element.className.search( /\bpostbitignored\b/ ) != -1

    };

    ret[type + '_id'] = parseInt( element.id.substr(5), 10 );

    var content = element.getElementsByClassName('content')[0];
    if ( content ) {
        ret.message         = $.trim(content.textContent);
        ret.message_element = $(content);
    }

    return ret;
}

VBulletin.prototype.process_posts = function(posts, parse_date) {
    // this is a surprisingly tight loop when downloading a large thread (up to 10,000 posts in quick succession),
    // so we mostly avoid jQuery and go straight to DOM accessors
    if ( !posts ) posts = this.get_posts();
    var process = this._process_post_note;
    return posts.map(function(post) {

        var ret = process(post, 'post');

        if ( !ret.is_ignored && !ret.is_deleted ) {

            var ip_element = post.getElementsByClassName('ip')[0];

            $.extend(ret, {
                linking        : $(post.getElementsByClassName('postlinking')[0]),
                ip_element     : ip_element ? $(ip_element) : $('<div>'),
                report_element : $(post.getElementsByClassName('report')[0]),
                ip             : ip_element ? ip_element.textContent.replace(/^\s*/, '').replace(/\s*$/, '') : '',
            },
            ret.is_moderated ? {} : {
                post_no        : parseInt( post.getElementsByClassName('iepostcounter')[0].textContent.substr(1), 10 ),
            });

            var edited = post.getElementsByClassName('lastedited');
            if ( edited.length ) {
                var a = $('a[href]', edited[0]);
                if ( a.length ) { // this has been seen false on the test site - might as well prepare in case it happens live some day
                    ret.edit_username = a.text().substr(15);
                    ret.edit_user_id  = parseInt( a.attr('href').split('?p=')[1], 10 );
                    edited[0].textContent.replace( /; ([^;]*?)\.\s*Reason:\s*(.*?)\s*$/, function(match, time, reason) {
                        ret.edit_time = time;
                        ret.edit_reason = reason;
                    });
                }
            }

        }

        return ret;

    });
}

VBulletin.prototype._get_token = function() {
    if ( this._origin ) {
        return this.security_token || 'guest';
    } else {
        var token = $('input[name="securitytoken"]').val(); // more reliable than this.security_token, as it is also set by vBulletin code
        if ( token ) return token;
        BabelExt.utils.runInEmbeddedPage( 'document.head.setAttribute("data-securitytoken", SECURITYTOKEN );' );
        var token = document.head.getAttribute('data-securitytoken');
        document.head.removeAttribute('data-securitytoken');
        return token;
    }
}

VBulletin.prototype._add_standard_data = function(data) {

    var dfd = $.Deferred();

    data = $.extend( data, this.standard_post_data );

    if ( !data.url ) {
        data.url = 'images/misc/navbit-home.png'; // redirect POST requests to a quick-to-load page
        data.ajax = 1; // some URLs will serve a lightweight page if passed this
    }

    if ( data.securitytoken = this._get_token() ) {
        dfd.resolve(data);
    } else {
        var bb = this;
        $(function() {
            if ( data.securitytoken = bb._get_token() ) {
                dfd.resolve(data);
            } else {
                debug_log.log("Fatal: could not get securitytoken");
                dfd.reject();
            }
        });
    }

    return dfd.promise();
}

VBulletin.prototype.detect_post_error = function(reply) {
    if ( reply.getElementsByTagName && reply.getElementsByTagName('error').length ) // XML response
        return reply.getElementsByTagName('error')[0].textContent
    else if ( reply.search && !reply.search(/^\s*</) ) { // looks like HTML
        if ( !this._origin ) reply.replace( /\bvar SECURITYTOKEN = "([^"]*)"/, function(match, securitytoken) {
            // contains a security token - replace our current token with this one
            $('input[name="securitytoken"]').val(securitytoken);
        });
        if (  reply.search(' class="standard_error"') != -1 ) { // HTML error
            var this_script = '';
            reply.replace( /var THIS_SCRIPT = "([^"]+)";/, function(match,_this_script) { this_script = _this_script });
            reply.replace( /<body([^]*<\/)body>/, function(body, body_innerHTML) { reply = $('<div'+body_innerHTML+'div>') });
            var noscript = reply.find( 'noscript' );
            if ( noscript.length && (
                noscript.html().search( /http-equiv="refresh"/i ) == -1 || // Automatic page refreshes generally indicate success, even when the success message has class="standard_error"
                this_script == "newthread" // automatic page refreshes during thread creation *do not* indicate success
            ))
                return $.trim(reply.find('.standard_error').text());
            else
                return null;
        } else if ( reply.search && reply.search( / class="[^"]*\b(?:blockrow\b[^"]*\berror\b|error\b[^"]*\bblockrow\b)[^>*]>(?!(?:&nbsp;)?<\/div>)/ ) != -1 ) {
            return $.trim( $(reply).find( '.blockrow.error' ).text() );
        } else {
            return null;
        }
    } else {
        return null;
    }
}

VBulletin.prototype.parse_post = function(name, post) {
    var bb = this;
    return post.message_element.find( '.bbcode_code').get()
        .reduce(function(prev,post) { return bb._parse( name, post.textContent, '^', '$' ) || prev }, null);
}

/*
 * ATTACHMENT FUNCTIONS
 */

/**
 * @summary delete an array of attachments
 * @param {Array.<Object>} attachments attachment info, as returned from post_info()
 * @return {jQuery.Promise}
 */
VBulletin.prototype.attachments_delete = function( attachments ) {
    var info = {
        do               : 'manageattach',
        upload           : 0,
        s                : '',
        contenttypeid    : 1,
        MAX_FILE_SIZE    : 2097152,
        'attachmenturl[]': '',
        ajax             : '1'
    };
    var requests = {};
    attachments.forEach(function(attachment) {
        if ( !requests.hasOwnProperty(attachment.info.posthash) ) requests[attachment.info.posthash] = $.extend( info, attachment.info );
        requests[attachment.info.posthash][ 'delete['+attachment.id+']' ] = 1;
    });
    var bb = this;
    return this.when(Object.keys(requests).map(function(key) { return bb.post( '/newattachment.php', requests[key] ) }));
}

/*
 * BBCODE FUNCTIONS
 */

/**
 * @summary Convert bbcode to HTML, for an existing thread
 * @param {Number} thread_id ID of thread to post in
 * @param {string} bbcode text to convert
 * @return {jQuery.Promise}
 */
VBulletin.prototype.bbcode_html = function( thread_id, bbcode ) {
    return this.post(
        '/newreply.php?do=postreply&t=' + thread_id,
        {
            message_backup: bbcode,
            message       : bbcode,
            'do'          : 'postreply',
            t             : thread_id,
            preview       : 'Preview Post',
        }
    ).then(
        function(html) {
            return $(html).find('.postcontent').html();
        }
    );
}

/**
 * @summary Convert bbcode to HTML, for a new thread in the specified forum
 * @param {Number} forum_id ID of forum to post in
 * @param {string} bbcode text to convert
 * @return {jQuery.Promise}
 */
VBulletin.prototype.bbcode_html_newthread = function( forum_id, bbcode ) {
    return this.post(
        '/newthread.php?do=postthread&f=' + forum_id,
        {
            'do': 'postthread',
            subject: 'Test converting bbcode to HTML',
            message_backup: bbcode,
            message: bbcode,
            f: forum_id,
            preview: 'Preview Post',
        }
    ).then(
        function(html) {
            return $(html).find('.postcontent').html();
        }
    );
}

/*
 * INFRACTION FUNCTIONS
 */

/**
 * @summary Give an infraction to a user
 * @param {Object} data infraction information
 * @return {jQuery.Promise}
 *
 * @example
 * bb.infraction_give({
 *     administrative_note: 'administrative note',
 *     ban_reason         : 'reason to show the user if the infraction triggers a ban',
 *     bbcode             : 'message body',
 *     user_id            : 1234, // must pass user_id or post_id
 *     post_id            : 2345,
 *     is_warning         : true,
 *     infraction_id      : 1
 * });
 */
VBulletin.prototype.infraction_give = function( data ) {
    var post_data = {
        do               : 'update',
        note             : data.administrative_note,
        banreason        : data.ban_reason,
        message          : data.bbcode,
        message_backup   : data.bbcode,
        infractionlevelid: data.infraction_id,
        savecopy         : 1,
        sbutton          : 'Give Infraction',
        p                : data.post_id,
        u                : data.user_id,
    };
    if ( data.is_warning ) post_data['warning['+data.infraction_id+']'] = 1;
    return this.post( '/infraction.php?do=update', post_data );
}

/**
 * @summary Give an infraction to a user
 * @param {Object} data infraction information
 * @return {jQuery.Promise}
 *
 * @example
 * bb.infraction_give_custom({
 *     administrative_note: 'administrative note',
 *     reason             : 'ban reason to show the user',
 *     bbcode             : 'message body',
 *     user_id            : 1234, // must pass user_id or post_id
 *     post_id            : 2345,
 *     is_warning         : true,
 *     points             : 2, // number of infraction points to assign the user
 *     period             : 'M', // Months, Days etc.
 *     expires            : 3 // number of periods
 * });
 */
VBulletin.prototype.infraction_give_custom = function( data ) {
    if ( data.is_warning ) points = 0;
    return this.post( '/infraction.php?do=update', {
        do            : 'update',
        note          : data.administrative_note,
        banreason     : data.reason,
        message       : data.bbcode,
        message_backup: data.bbcode,
        savecopy      : 1,
        sbutton       : 'Give Infraction',
        p             : data.post_id,
        u             : data.user_id,

        infractionlevelid: 0,
        customreason     : data.reason,
        points           : data.points,
        expires          : data.expires,
        period           : data.period,
    });
}

/**
 * @summary get the valid infractions IDs for a user
 * @param {string=} user_id ID of user to get (default: 37)
 * @return {jQuery.Promise}
 *
 * @description
 *
 * All users should have the same infractions available, but there's
 * no API to retrieve infraction without a user ID.  User ID 37 is
 * high enough that it's unlikely to be a special un-infractable user,
 * but low enough it's likely to exist.  You should only need to pass
 * a different ID if the above isn't true of your user #37
 */
VBulletin.prototype.infraction_ids = function( user_id ) {

    return this.get( this.url_for.infraction({ action: 'report', user_id: user_id || 37 }) ).then(function(html) {
        return $(html).find('input[name="infractionlevelid"]').map(function() {
            if ( $(this).val() != '0' ) {
                var name = $.trim($(this).parent().text());
                return {
                    name  : name,
                    id    : parseInt( $(this).val(), 10 ),
                    points: parseInt( $(this).closest('td').next().text(), 10 )
                }
            }
        }).get();
    });

}

/*
 * POST FUNCTIONS
 */

/**
 * Monitor the list of posts
 * @param {function} callback function to call when a post is modified
 */
VBulletin.prototype.on_posts_modified = function( callback ) {

    function observe_mutation(mutations) {
        var modifications = {
            initialised: [],
            edited     : [],
        }, has_modifications = false;
        mutations.forEach(function(mutation) {
            var post_id = $(mutation.target).closest('li').attr('id') || $(mutation.target).children('li').attr('id');
            if ( !post_id ) return;
            post_id = parseInt( post_id.substr(5), 10 );
            if ( $(mutation.target).find('blockquote').length ) {
                has_modifications = true;
                modifications.initialised.push( post_id );
            } else if ( $(mutation.target).find('.texteditor').length ) {
                has_modifications = true;
                modifications.edited.push( post_id );
            }
        });
        if ( has_modifications ) callback( modifications );
    }
    var observer;
    if      ( typeof(      MutationObserver) != 'undefined' ) observer = new       MutationObserver(observe_mutation);
    else if ( typeof(WebKitMutationObserver) != 'undefined' ) observer = new WebKitMutationObserver(observe_mutation);
    $('#posts').each(function() { observer.observe(this, { childList: true, subtree: true }) });

}

/**
 * Create a new element that resembles a post
 * @param {string} date           text to show as the date
 * @param {string} username       text to show as the user's name
 * @param {string} user_title     text to show as the user's title (e.g. "moderator")
 * @param {string} post_title     text to show as the post title
 * @param {string} post_body_html HTML to show as the post body
 * @return {jQuery} new post element
 */
VBulletin.prototype.post_create = function( date, username, user_title, post_title, post_body_html ) {

    var post = $('#posts li').first().clone();

    post.attr( 'id', 'post_0' );
    post.find('[id]'               ).removeAttr( 'id' );
    post.find('.date'              ).text( date );
    post.find('.postdetails'       ).attr( 'class', 'postdetails' ); // remove flare etc.
    post.find('.iepostcounter'     ).text( '#0' );
    post.find('.username'          ).hide().attr( 'href', '' ).after( $('<b class="username"></b>').text( username ) );
    post.find('.memberaction_body' ).remove();
    post.find('.onlinestatus'      ).remove();
    post.find('.usertitle'         ).text(user_title);
    post.find('.postbit_reputation').remove();
    post.find('.postuseravatar'    ).remove();
    post.find('.userinfo_extra'    ).remove();
    post.find('.title'             ).text(post_title);
    post.find('.postrow'           ).removeClass('has_after_content');
    post.find('blockquote'         ).html(post_body_html);
    post.find('.after_content'     ).remove();
    post.find('.postfoot'          ).remove();

    return post;

}

/**
 * @summary Soft-delete a post
 * @param {Number} post_ID ID of post to retrieve
 * @param {string=} reason  deletion reason
 * @return {jQuery.Promise}
 * @description VBulletin supports hard-deleting posts (or just their attachments).
 * We do not expose this functionality because there is no circumstance where a
 * moderator would want to destroy their paper trail.
 */
VBulletin.prototype.post_delete = function( post_id, reason ) {
    return this.post(
        '/editpost.php',
        {
            do        : 'deletepost',
            postid    : post_id,
            reason    : reason,
            deletepost: 'delete',
            keepattachments: 1
        }
    );
}

/* This would save us some page requests, but sometimes forces us to log in again which negates any time benefit:
VBulletin.prototype.post_delete_multi = function( post_ids, reason ) {
    return this.post(
        '/inlinemod.php?postids=' + post_ids.join(),
        {
            'do': 'dodeleteposts',
            postids: post_ids.join(),
            deletereason: reason,
            deletepost: 'delete',
            deletetype: 1,
            p: 0,
            postid: 0
            // TODO: figure out what to send for "keepattachments" if we ever put this back in
        }

    );
}
*/

/**
 * @summary Change the contents of a post
 * @param {Object} data
 * @return {jQuery.Promise}
 *
 * @example
 * bb.post_edit({
 *     post_id: 123,
 *     bbcode : 'post [i]body[/i]',
 *     reason : 'reason for change', // optional, default: keep old reason
 * });
 */
VBulletin.prototype.post_edit = function( data ) {
    return this.post(
        '/editpost.php?do=updatepost&p=' + data.post_id,
        {
            do            : 'updatepost',
            p             : data.post_id,
            reason        : data.reason,
            //title         : data.title, // doesn't work - VBulletin is more reluctant to set this than I can be bothered to test
            message       : data.bbcode,
            message_backup: data.bbcode
        }
    );
}

/**
 * @summary Get information about a post (vBCode and attachment info)
 * @param {Number} post_ID ID of post to retrieve
 * @return {jQuery.Promise}
 */
VBulletin.prototype.post_info = function( post_id ) {

    var bb = this;

    return this.post('/ajax.php?do=quickedit&p=' + post_id, {
        do: 'quickedit',
        p : post_id
    }).then(function(xml) {

        var post = $(xml.getElementsByTagName('editor')[0].textContent);
        var ret = {
            bbcode: post.find('#vB_Editor_QE_editor').text(),
        };

        // no attachments:
        if ( !post.find('input[id="cb_keepattachments"]').length ) return ret; // '#cb_keepattachments' doesn't work for some reason

        var ckeconfig = JSON.parse(xml.getElementsByTagName('ckeconfig')[0].textContent);
        var attachment_info = {
            securitytoken: ckeconfig.vbulletin.securitytoken,
            posthash     : ckeconfig.vbulletin.attachinfo.posthash,
            poststarttime: ckeconfig.vbulletin.attachinfo.poststarttime,
            'values[p]'            : post_id,
            'values[poststarttime]': ckeconfig.vbulletin.attachinfo.poststarttime,
            'values[posthash]'     : ckeconfig.vbulletin.attachinfo.posthash
        };

        return bb.get( '/newattachment.php', {
            do           : 'assetmanager',
            'values[p]'  : post_id,
            editpost     : 1,
            contenttypeid: 1,
            insertinline : 1,
            posthash     : ckeconfig.vbulletin.attachinfo.posthash,
            poststarttime: ckeconfig.vbulletin.attachinfo.poststarttime,
        }).then(function(html) {
            ret.attachments = $(html).find('div.asset_div').map(function() {
                return {
                    // assets appear to have an asset ID and an attachment ID (in case they're e.g. attached to multiple posts)
                    // we only care about the attachment ID:
                    id       : $( '.asset_attachment_container', this ).attr('id').split('_')[2],
                    thumbnail: $('.asset_attachment,.asset_attachment_nothumb', this).attr( 'src' ),
                    filename : $('.filename', this).attr( 'title' ),
                    info     : attachment_info
                };
            }).get();
            return ret;
        });

    });
}

/**
 * @summary move an array of posts to the specified thread
 * @param {Number}         thread_id destination thread ID
 * @param {Array.<Number>} post_ids posts to move
 */
VBulletin.prototype.posts_move = function( thread_id, post_ids ) {
    return this.post(
        '/inlinemod.php?do=domoveposts&t=' + thread_id + '&postids=' + post_ids,
        {
            do            : 'domoveposts',
            type          : 1,
            mergethreadurl: '/showthread.php?t=' + thread_id,
            t             : thread_id,
            postids       : post_ids.join()
        }
    );
}

/**
 * @summary Report a post
 * @param {Number} post_id  ID of post to report
 * @param {string} bbcode   report body
 * @param {string} ajax_url URL to return from AJAX request
 * @return {jQuery.Promise}
 */
VBulletin.prototype.post_report = function( post_id, bbcode, ajax_url ) {
    return this.post(
        '/report.php?do=sendemail',
        {
            do    : 'sendemail',
            postid: post_id,
            reason: bbcode,
            url   : ajax_url
        }
    );
}

/**
 * @summary Get summary text for a post returned by process_posts()
 * @param {Object} post post to summarise
 * @return {string} short summary
 */
VBulletin.prototype.post_summary = function( post ) {

    if ( post.title ) return post.title;

    var message;
    if ( post.hasOwnProperty('message_element') ) {
        message = post.message_element.clone()
        message.find('.bbcode_container,.spoiler').remove();
        message = $.trim(message.text());
    } else {
        message = post.message;
    }

    return message
        .replace( /\s+/g, ' ' )
        // if there are more than 15 words, truncate after the first 10:
        .replace( /^(\S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+) \S+ \S+ \S+ \S+ \S+ .*/, "$1\u2026" );

}

/**
 * @summary Send a private message
 * @param {string} to                username(s) to send to
 * @param {string} title             message title
 * @param {string} bbcode            message body
 * @param {boolean=} request_receipt whether to request a message receipt
 * @return {jQuery.Promise}
 */
VBulletin.prototype.pm_send = function( to, title, bbcode, request_receipt ) {
    return this.post(
        '/private.php?do=insertpm',
        {
            do            : 'insertpm',
            title         : title,
            message       : bbcode,
            message_backup: bbcode,
            recipients    : to,
            savecopy      : 1,
            sbutton       : 'Submit Message',
            receipt       : request_receipt ? 1 : undefined
        }
    );
}

/**
 * @summary Get recent private messages in a folder
 * @param {Number} folder_id folder to download
 * @return {jQuery.Promise}
 */
VBulletin.prototype.folder_pms = function( folder_id ) {
    return this.get( '/private.php?folderid=' + folder_id ).then(function(html) {
        return $(html).find('.pmbit').map(function() {
            var $this = $(this);
            return {
                container_element: $this,
                pm_id    : parseInt( this.id.substr(3), 10 ),
                date     : $.trim($this.find( '.datetime' ).text()),
                user_id  : parseInt( $this.find('.username').attr('href').split('?u=')[1], 10 ),
                user_name: $this.find('.username').text(),
                title    : $this.find('.title').text()
            }
        }).get();
    });
}

/*
 * THREAD MANAGEMENT
 */

/**
 * @summary Bump a thread
 * @param {Number} thread_ID ID of thread to bump
 * @return {jQuery.Promise}
 */
VBulletin.prototype.thread_bump = function(thread_id) {
    return this.post(
        '/postings.php',
        {
            do: 'vsa_makenewer',
            t : thread_id
        }
    );
}

/**
 * @summary De-bump a thread
 * @param {Number} thread_ID ID of thread to de-bump
 * @return {jQuery.Promise}
 */
VBulletin.prototype.thread_debump = function(thread_id) {
    return this.post(
        '/postings.php',
        {
            do: 'vsa_makeolder',
            t : thread_id
        }
    );
}

/**
 * @summary suggest possible completions given a partial thread title
 * @param {string} substring partial thread title
 * @return {Array.<Object>} list of thread titles and IDs
 */
VBulletin.prototype.threads_complete = function(substring) {
    return this.post(
        '/search.php?do=process',
        {
            do: 'process',
            contenttypeid: 1,
            query: substring,
            titleonly: 1,
            showposts: 0,
            searchfromtype: 'vBForum:Post'
        }
    ).then(function(html) {
        return $(html).find( '.title' ).map(function() {
            return {
                thread_id: parseInt( this.id.split('_')[2], 10 ),
                title: $(this).text(),
                forum_id: parseInt( $(this).closest('li').find('.threadpostedin a').attr('href').split('?f=')[1], 10 )
            }
        }).get();
    });
}

/**
 * @summary list recent threads with at least one reply, sorted by reply count
 * @param {string} substring partial thread title
 * @return {Array.<Object>} list of thread titles and IDs
 */
VBulletin.prototype.threads_recent = function(substring) {
    return this.post(
        '/search.php?do=process',
        {
            do: 'process',
            beforeafter: 'after',
            childforums: 1,
            contenttypeid: 1,
            order: 'descending',
            replyless: 0,
            replylimit: 1,
            searchdate: 1,
            searchfromtype: 'vBForum:Post',
            showposts: 0,
            sortby: 'replycount'
        }
    ).then(function(html) {
        // if multiple pages, get all pages
        return $(html).find( '.title' ).map(function() {
            return {
                thread_id: parseInt( this.id.split('_')[2], 10 ),
                title: $(this).text(),
                forum_id: parseInt( $(this).closest('li').find('.threadpostedin a').attr('href').split('?f=')[0], 10 )
            }
        }).get();
    });
}

/**
 * @summary Create a new thread
 * @param {Object} data
 * @return {jQuery.Promise}
 * @example
 * bb.thread_create({
 *     forum_id: 12,
 *      icon_id: 1,
 *     prefix  : 'some_prefix',
 *     title   : 'thread title',
 *     bbcode  : 'thread bbcode'
 *     close_thread: true, // optional, default: thread is open
 *     stick_thread: true, // optional, default: thread is not sticky
 * });
 */
VBulletin.prototype.thread_create = function(data) {
    return this.post(
        '/newthread.php?do=postthread&f=' + data.forum_id,
        {
            do            : 'postthread',
            f             : data.forum_id,
            iconid        : data.icon_id,
            prefixid      : data.prefix_id,
            subject       : data.title,
            message_backup: data.bbcode,
            message       : data.bbcode,
            sbutton       : 'Submit New Thread',
            openclose     : data.close_thread ? 1 : undefined,
            stickunstick  : data.stick_thread ? 1 : undefined,
        }
    ).then(function(html) {
        return $(html).find( 'input[name="t"]' ).val();
    });
}

/**
 * @summary Delete thread
 * @param {Object} data
 * @return {jQuery.Promise}
 * @example
 * bb.thread_delete({
 *     thread_id: 123,
 *     reason   : 'reason for deletion',
 * });
 * @description this soft-deletes a post - physically removing posts is not supported
 */
VBulletin.prototype.thread_delete = function( data ) {
    return this.post( '/postings.php?do=dodeletethread&threadid=' + data.thread_id, {
        deletereason: data.reason,
        deletetype: 1, // soft delete
        do: 'dodeletethread',
        t: data.thread_id
    });
}

/**
 * @summary Change thread metadata
 * @param {Object} data
 * @return {jQuery.Promise}
 * @example
 * bb.thread_edit({
 *     thread_id       : 123,
 *     title           : 'post title',
 *     notes           : 'edit notes'
 *     icon_id         : 1, // optional
 *     prefix_id       : 1, // optional, default: no prefix
 *     close_thread    : true, // optional, default: open thread
 *     unapprove_thread: true, // optional, default: do not unapprove
 *     delete_thread   : true // optional, default: leave in current state
 *     delete_reason   : 'reason why thread was deleted',
 * });
 */
VBulletin.prototype.thread_edit = function( data ) {
    return this.post( '/postings.php?do=updatethread&t=' + data.thread_id, {
        do      : 'updatethread',
        title   : data.title,
        notes   : data.notes,
        iconid  : data.icon_id,
        prefixid: data.prefix_id,
        visible : data.unapprove_thread ? 'no' : 'yes',
        open    : (
            ( typeof(data.close_thread) === 'undefined' ) ? 'yes' :
                     data.close_thread                    ? ''    :
                                                            'yes'
        ),

        keepattachments: data.delete_thread ? 'yes'       : undefined,
        reason         : data.delete_thread ? data.reason : undefined,
        threadstatus   : (
            ( typeof(data.delete_thread) === 'undefined' ) ? undefined :
                     data.delete_thread                    ? 0         :
                                                             1
        )
    });
}

/**
 * @summary Merge a set of threads together
 * @param {Object} data
 * @return {jQuery.Promise}
 *
 * @example
 * bb.thread_merge({
 *     forum_id  : 12,
 *     thread_ids: [ 123, 234, 345 ],
 *     url       : '/foo.php' // optional, default: post with AJAX to avoid page load
 * });
 */
VBulletin.prototype.thread_merge = function( data ) {
    return this.post(
        '/inlinemod.php?do=domergethreads&threadids=' + data.thread_ids.join(),
        {
            do           : 'domergethreads',
            destthreadid : data.thread_ids.sort( function(a, b) { return a-b } )[0],
            threadids    : data.thread_ids.join(),
            destforumid  : data.forum_id,
            skipclearlist: 1,

            // add a redirect that expires after five days:
            redir: 1,
            redirect: 'expires',
            period: this.redirect_duration.period,
            frame : this.redirect_duration.frame,

            url: data.url
        },
        data.url
    );
}

/**
 * @summary Move a thread from one forum to another
 * @param {Object} data
 * @return {jQuery.Promise}
 *
 * @example
 * bb.thread_move({
 *     thread_id     : 123,
 *              title: 'new thread title',
 *     redirect_title: 'thread title for redirect',
 *     forum_id      : 12,
 *     redirect      : { period: 1, frame: 'w' }, // see parse_duration()
 *     url           : '/foo.php' // optional, default: post with AJAX to avoid page load
 * });
 */
VBulletin.prototype.thread_move = function( data ) {
    return this.post(
        '/postings.php?domovethread&t=' + data.thread_id,
        $.extend(
            data.redirect ? { enableredirect: 1, redirect: 'expires', period: data.redirect.period, frame: data.redirect.frame } : {},
            {
                do: 'domovethread',
                t: data.thread_id,
                destforumid: data.forum_id,
                redirecttitle: data.redirect_title || data.title,
                title: data.title,
                url: data.url
            }
        ),
        data.url
    );
}

/**
 * @summary Get metadata about a thread
 * @param {Number} thread_id
 * @return {jQuery.Promise}
 */
VBulletin.prototype.thread_metadata = function( thread_id ) {
    var bb = this;
    return this.get( '/postings.php?do=editthread&t=' + thread_id ).then(function(html) {
        html = $(html);
        return $.extend( bb._forum_thread_metadata(html), {
            title: html.find('#title').val(),

            notes: html.find('[name="notes"]').val(),

            valid  : !!html.find('#cb_open').length,
            open   :   html.find('#cb_open').is(':checked'),
            deleted: !!html.find('[name="threadstatus"]').length,
            merged : !!html.find('#rb_redirect_expires').length,

            sticky: html.find('#cb_sticky').is(':checked'),
            moderated: !html.find('#cb_visible').is(':checked'),

            delete_reason: html.find('[name="reason"]').val()

        });
    });
}

/**
 * @summary Get the title of a thread
 * @param {string|HTMLElement=} thread thread to get page for (default: current page)
 * @return string
 */
VBulletin.prototype.thread_title = function( thread ) {
    return $.trim( $(thread || this.doc).find( '.threadtitle').first().text() );
}

/**
 * @summary Open or close a thread
 * @param {Number} thread_id ID of thread to open or close
 * @param {boolean} open whether the thread status should be set to "open" (instead of "closed")
 * @return {jQuery.Promise}
 *
 * @description
 * Note: this is guaranteed to perform the desired action,
 *       whereas thread_reply() suffers from race conditions
 */
VBulletin.prototype.thread_openclose = function( thread_id, open ) {
    return this.post( '/ajax.php?do=updatethreadopen&t=' + thread_id, {
        do  : 'updatethreadopen',
        t   : thread_id,
        open: open ? 'true' : 'false'
    });
}

/**
 * @summary Create a new reply in a thread (AJAX)
 * @param {Object} data
 * @return {jQuery.Promise}
 *
 * @example
 * bb.thread_reply({
 *     thread_id           : 123,
 *     title               : 'post title', // optional, default: no title
 *     bbcode              : 'post [i]body[/i]',
 *     flip_thread_openness: false,
 *     url                 : '/foo.php' // optional, default: post with AJAX to avoid page load
 * }).then(function(new_post_id) {
 *     ...
 * });
 *
 * @description
 * Note: 'flip_thread_openness' suffers from a race condition -
 *       when two people flip the state at the same time, it cancels out.
 *       See {@link thread_openclose} for a safer solution.
 */
VBulletin.prototype.thread_reply = function( data ) {
    var ret = this.post(
        '/newreply.php?do=postreply&t=' + data.thread_id,
        {
            do            : 'postreply',
            t             : data.thread_id,
            title         : data.title,
            message       : data.bbcode,
            message_backup: data.bbcode,
            openclose     : data.flip_thread_openness ? 1 : 0,
            sbutton       : ( data.url ? undefined : 'Submit Reply' ),
            url           :   data.url,
            subscribe     : 1
        },
        data.url
    );
    if ( data.url )
        return ret;
    else
        return ret.then(function(xml) {
            return xml.getElementsByTagName('postbit')[0].getAttribute('postid');
        });
}

/**
 * @summary Get the list of posters in a thread
 * @param {Number} thread_id ID of thread to check
 * @return {jQuery.Promise}
 *
 * @description Note: this is quite inefficient for threads with over 2,500 posts
 */
VBulletin.prototype.thread_whoposted = function( thread_id ) {
    return this.get( '/misc.php?do=whoposted&t=' + thread_id ).then(function(html) {
        html = $(html);
        return {
            total: html.find('.stats.total dd').text(),
            users: html.find('#whoposted .blockrow').map(function() { // '#whoposted' is needed on forums with debugging enabled
                return {
                    user_id   : parseInt( $('.username a', this).attr('href').split('?u=')[1], 10 ),
                    username  : $('.username a', this).text(),
                    post_count: parseInt( $('.stats a', this).text(), 10 )
                }
            }).get()
        };
    });
}

/*
 * USER FUNCTIONS
 */

/**
 * @summary Ban a user
 * @param {string} user     name of user to ban
 * @param {string} reason   reason to show the user
 * @param {string} group_id set the user to this group
 * @return {jQuery.Promise}
 *
 * @example
 * bb.infraction_give({
 *     username: 'user name',
 *     group_id: 123, // set the user to this group
 *     reason  : 'reason to show user',
 *     period  : 'M', // Months, Days etc.
 *     expires : 3 // number of periods
 * });
 */
VBulletin.prototype.user_ban = function( data ) {
    return this.post( '/modcp/banning.php?do=dobanuser', {
        do         : 'dobanuser',
        username   : data.username,
        usergroupid: data.group_id,
        period     : ( data.period == 'PERMANENT' ? data.period : data.period + '_' + data.expires ),
        reason     : data.reason
    });
}

/**
 * @summary actually get paramaters to pass to a ModCP request
 * @private
 * @return {Object}
 */
VBulletin.prototype._parse_modcp_data = function(html) {
    html = $(html);
    return {
        adminhash    : html.find('input[name="adminhash"]'    ).val(),
        securitytoken: html.find('input[name="securitytoken"]').val()
    };
}

/**
 * @summary get parameters to pass to a ModCP request
 * @private
 * @return {jQuery.Promise}
 */
VBulletin.prototype._get_modcp_data = function() {
    if ( ! this._modcp_data )
        this._modcp_data = this.get( '/modcp/user.php?do=doips' ).then(this._parse_modcp_data);
    return this._modcp_data;
}

/**
 * @summary get IP addresses used by an account
 * @param {Object} user user to check (must contain "username" or "user_id")
 * @param {boolean} get_overlapping whether to also return the list of other accounts using those IPs
 * @return {jQuery.Promise}
 * @description
 * Note: "overlapping" users includes people that share the same
 * house, share an ISP which uses dynamic IP addresses, or just happen
 * to have used the same router one time.
 */
VBulletin.prototype.user_ips = function( user, get_overlapping ) {

    var bb = this;

    return this._get_modcp_data().then(function(data) {
        // Note: this page accepts a "userid" parameter, even though there's no such input in the form:
        return bb.post( '/modcp/user.php?do=doips', $.extend( {}, data, { do: 'doips', username: user.username, userid: user.user_id, depth: get_overlapping ? 2 : 1 } ) ).then(function(html) {
            html = $(html);
            var ret = {
                registration_ip: html.find('#cpform_table .alt1').eq(1).text(),
                used_ips       : html.find('#cpform_table td > ul > li' ).map(function() { var ip = $(this).children('a').first().text(); if ( ip != '127.0.0.1' ) return ip }).get(),
                overlapping_users: {},
            }
            ret.unique_ip_count = ret.used_ip_count = ret.used_ips.length;
            var overlapping_ips = {};
            html.find( '#cpform_table ul ul > li' ).each(function() {
                var elements = $(this).children('a');
                var name     = elements.eq(0).text(),
                user_id  = parseInt( elements.eq(0).attr('href').split('&u=')[1], 10 ),
                address  = elements.eq(1).text()
                ;
                if ( address === '127.0.0.1' ) return; // ignore localhost
                if ( !overlapping_ips[address]++ ) --ret.unique_ip_count;
                if ( ret.overlapping_users[name] ) {
                    ret.overlapping_users[name].addresses.push( address );
                } else {
                    ret.overlapping_users[name] = {
                        user_id: user_id,
                        addresses: [ address ]
                    };
                }
            });
            return ret;
        });
    });

}

/**
 * @summary get accounts used by an IP address
 * @param {string} ip IP address to check
 * @return {jQuery.Promise}
 */
VBulletin.prototype.ip_users = function( ip ) {

    var bb = this;

    return this._get_modcp_data().then(function(data) {

        return bb.post( '/modcp/user.php?do=doips', $.extend( {}, data, { do: 'doips', ipaddress: ip, depth: 1 } ) ).then(function(html) {
            html = $(html);
            var domain_name = html.find('#cpform_table .alt1 b').first().text();
            if ( domain_name == 'Could Not Resolve Hostname' ) domain_name = ip;
            var previous_users = {};
            return {
                ip: ip,
                domain_name: domain_name,
                users: html.find('#cpform_table li a b').parent().map(function() {
                    var user_id = parseInt( $(this).attr('href').split('&u=')[1], 10 );
                    if ( !previous_users.hasOwnProperty(user_id) ) {
                        previous_users[user_id] = 1;
                        return {
                            username: $(this).text(),
                            user_id: user_id
                        }
                    }
                }).get()
            };
        });

    });

}

/**
 * @summary get users that have used the same IP address
 * @param {string} username user to check
 * @return {jQuery.Promise}
 * @description
 * Note: This includes any use of the same address (registration or posting)
 */
VBulletin.prototype.user_overlapping = function( user ) {

    // VBulletin's "overlapping IP search" doesn't catch overlapping registration addresses,
    // so we have to do quite a lot of requests.

    var bb = this, users = {}, ret = [];

    return bb.user_ips(user).then(function(user_ips) {
        return $.when.apply( // Search for associated users
            $,
            [user_ips.registration_ip].concat(user_ips.used_ips).map(function(ip) {
                return bb.ip_users(ip).then(function(ip_users) {
                    ip_users.users.forEach(function(overlapping_user) {
                        if (
                            !( user.username && user.username == overlapping_user.username ) &&
                            !( user.user_id  && user.user_id  == overlapping_user.user_id  )
                        ) {
                            if ( !users.hasOwnProperty(overlapping_user.user_id) ) {
                                users[overlapping_user.user_id] = overlapping_user;
                                ret.push(overlapping_user);
                            }
                        }
                    });
                });
            })
        ).then(function() {
            return ret;
        });
    });

}


/**
 * @summary Add a note to a user's account
 * @param {string} user_id ID of note recepient
 * @param {string} title  message title
 * @param {string} bbcode message body
 * @return {jQuery.Promise}
 */
VBulletin.prototype.usernote_add = function( user_id, title, bbcode ) {
    return this.post(
        '/usernote.php?do=donote&u=' + user_id,
        {
            do            : 'donote',
            title         : title,
            message       : bbcode,
            message_backup: bbcode,
        }
    );
}

/**
 * @summary Get user notes for a user
 * @param {Number} user_id ID of the user
 * @return {jQuery.Promise}
 */
VBulletin.prototype.user_notes = function( user_id ) {
    var bb = this;
    return this.get( '/usernote.php?u=' + user_id ).then(function(html, status, jqXHR) {
        var parse_date = bb.date_parser( html, jqXHR );
        return $(html).find('#posts > li').map(function() {
            return bb._process_post_note( this, 'note', parse_date )
        }).get();
    });
}

/**
 * @summary Get information about a user note
 * @param {string} note_id ID of the note
 * @return {jQuery.Promise}
 */
VBulletin.prototype.usernote_info = function( note_id ) {
    return this.get( '/usernote.php?do=editnote&usernoteid=' + note_id ).then(function(html) {
        html = $(html);
        return {
            title : html.find('#titlefield').val(),
            bbcode: html.find('#vB_Editor_001_editor').val()
        }
    });
}

/**
 * @summary Change the contents of a user note
 * @param {string} note_id ID of the note
 * @param {string} title  message title
 * @param {string} bbcode message body
 * @return {jQuery.Promise}
 */
VBulletin.prototype.usernote_edit = function( note_id, title, bbcode ) {
    return this.post(
        '/usernote.php?do=donote&usernoteid=' + note_id,
        {
            do            : 'donote',
            title         : title,
            message       : bbcode,
            message_backup: bbcode,
        }
    );
}

/**
 * @summary Get information about the current user
 * @return {Object} username and user_id
 */
VBulletin.prototype.user_current = function() {
    var link = $('.welcomelink a');
    return (
        link.length
        ? { username: link.text(), user_id: link.attr('href').split('?u=')[1] }
        : null
    );
}

/**
 * @summary Get information about a user from their member page
 * @param {Number} user_id ID of user to gather information about
 * @return {jQuery.Promise}
 */
VBulletin.prototype.user_info = function(user_id) {
    var bb = this;
    return this.get('/member.php?u='+user_id+'&tab=infractions&pp=' + bb.default_reply_count).then(function(html, status, jqXHR) {

        // ignore non-existant users:
        if ( html.search( /<div class="standard_error">/ ) != -1 ) return;

        var parse_date = bb.date_parser( html, jqXHR );
        html = $(html);

        var stats = {};
        html.find('.member_blockrow > dl').each(function() { stats[ $('dt',this).text() ] = $('dd',this) });

        var join_date = $.trim(stats['Join Date'].text());
        var title     = $.trim(html.find('#userinfo .usertitle').text());
        var username  = $.trim(html.find('.member_username').text());

        var ret = {
            username: username,
            user_id : user_id,
            joined: join_date,
            join_date: parse_date(join_date),
            activity_date: parse_date($.trim(stats['Last Activity'].text())),
            title : title,

            user_note_count: parseInt( html.find('a[href="usernote.php?u='+user_id+'"]').text().replace( /^(?:.*[^0-9])?\(([0-9]+)\).*/, "$1" ), 10 ),

            summary: '<a href="/member.php?u=' + user_id + '">' + $('<div/>').text(username).html() + '</a>',

            infraction_reasons: [],
            infraction_count  : 0,
            warning_count     : 0,
            infraction_points : 0,
            infraction_summary: '',

            join_summary: 'joined ' + $('<div/>').text(join_date).html() + ', ' + $('<div/>').text(title).html()
        };

        if ( html.find('.infractions_block').length ) {

            var infractions = html.find( '#infractionslist > li' ); // all infractions, even those expired or reversed

            ret.infractions = infractions.map(function() {
                var post_link = $('.inflistinfo a', this), status = $.trim( $('.inflistexpires', this ).text() ), start_date = parse_date( $( '.inflistdate', this ).text() );
                switch ( status ) {
                case 'Expired' : status = { status: 'expired' , end_date: start_date                 }; break;
                case 'Reversed': status = { status: 'reversed', end_date: null                       }; break;
                case 'Never'   : status = { status: 'active'  , end_date: new Date(8640000000000000) }; break; // highest supported date
                default        : status = { status: 'active'  , end_date: parse_date(status)         }; break;
                }
                return $.extend(
                    {
                        type    : ( $('.inlineimg', this).attr('src').search( 'redcard' ) == -1 ) ? 'warning' : 'infraction',
                        reason  : $( '.infraction_reason em', this ).text(),
                        user_id : parseInt( $( '.postby a', this ).attr( 'href' ).split( '?u=' )[1], 10 ),
                        username:           $( '.postby a', this ).text(),
                        points  : parseInt( $.trim($( '.inflistpoints', this ).text()), 10 ),
                        start_date: start_date
                    },
                    status,
                    post_link.length ? {
                          post_id: parseInt( post_link.attr('href').replace( /.*[?&]p=([0-9]*).*/, "$1" ), 10 ),
                        thread_id: parseInt( post_link.attr('href').replace( /.*[?&]t=([0-9]*).*/, "$1" ), 10 ),
                        thread_title: post_link.text()
                    } : {}
                );
            }).get();

            var interesting_user_notes = ret.user_note_count - infractions.length;
            if ( interesting_user_notes > 0 ) {
                ret.infraction_summary += (
                    '<a href="/usernote.php?u='+user_id+'" title="' +
                    ( ( ret.user_note_count == 1 ) ? '1 user note' : ret.user_note_count + ' user notes'  ) +
                    '">' +
                    new Array( interesting_user_notes + 1 ).join( '&#x2669;' ) +
                    '</a>'
                );
            }

            html.find( '#infractions' ).text().replace( '[0-9]+', function( points ) { ret.infraction_points = points });

            infractions = infractions.filter(function() { return $('.inflistexpires',this).text().search(/Reversed/) == -1 });

            ret.infraction_reasons = infractions.map(function() { return $('.infraction_reason em', this).text() }).get();

            infractions = infractions.filter(function() { return $('.inflistexpires',this).text().search(/Expired/) == -1 });

            ret.infraction_count  = infractions.length;
            ret.warning_count     = infractions.has('img.inlineimg[src="images/misc/yellowcard_small.gif"]').length;
            ret.infraction_summary += (
                infractions.closest('li').map(function() {
                    var info = $(this).find('.inflistinfo');
                    var date = $(this).find('.inflistdate');
                    date.find('.postby').remove();
                    if ( info.find('a').length ) { // post-related infraction
                        return (
                            '<a href="'    + $('<div/>').text(info.find('a').attr('href')).html() +
                                '" title="'     + $('<div/>').text($.trim(date.text()) + ': ' + info.find('em').text()).html() +
                                '"><img src="/' + $('<div/>').text(info.find('img').attr('src')).html() +
                                '"></a>'
                        );
                    } else {
                        return (
                            '<span title="'     + $('<div/>').text($.trim(date.text()) + ': ' + info.find('em').text()).html() +
                                '"><img src="/' + $('<div/>').text(info.find('img').attr('src')).html() +
                                '"></span>'
                        );
                    }
                }).get().reverse().join('')
            );

        } else if ( ret.user_note_count ) {

            ret.infraction_summary += (
                '<a href="/usernote.php?u='+user_id+'" title="' +
                ( ( ret.user_note_count == 1 ) ? '1 user note' : ret.user_note_count + ' user notes'  ) +
                '">' +
                new Array( ret.user_note_count + 1 ).join( '&#x2669;' ) +
                '</a>'
            );

        }

        if ( html.find('#usermenu a[href^="modcp/banning.php?do=liftban"]').length ) {
            return bb.get( '/modcp/banning.php?do=editreason&userid=' + user_id ).then(function(html) {
                ret.is_banned = true;
                // ignore warnings/infractions/notes for banned users:
                ret.infraction_summary = '<span style="color: red">BANNED: ' + $('<div/>').text($.trim($(html).find( '#it_reason_1' ).val())).html() + '</span>';
                ret.summary = ret.infraction_summary + ' ' + ret.summary;
                return ret;
            });
        } else {
            ret.summary = ret.infraction_summary + ' ' + ret.summary;
            return ret;
        }
    });
}

/**
 * @summary Get information about a user from ModCP
 * @param {Number} user_id ID of user to gather information about
 * @return {jQuery.Promise}
 */
VBulletin.prototype.user_moderation_info = function(user_id) {

    var bb = this;

    return this.get( '/modcp/user.php?do=viewuser&u=' + user_id ).then(function(html) {
        html = $(html);

        var name = html.find( '[name="user\\[username\\]"]' ).val();

        if ( !name || !name.length ) return null; // user doesn't exist

        var image = html.find( 'img[src^="../image.php"]').attr( 'src' );

        var primary_group = html.find( '[name="user\\[usergroupid\\]"] :selected' ).text();
        var additional_groups = html.find('[name="membergroup\\[\\]"]:checked' ).map(function() { return $(this.parentNode).text() }).get();

        function get_date(type) {
            var div = html.find('#ctrl_' + type);
            var month = div.find('[name="' + type + '\\[month\\]"]').val();
            if ( month == 0 ) return null;
            return new Date(
                div.find('[name="' + type + '\\[year\\]"]').val(),
                parseInt(month,10)-1,
                div.find('[name="' + type + '\\[day\\]"]').val(),
                div.find('[name="' + type + '\\[hour\\]"]').val(),
                div.find('[name="' + type + '\\[minute\\]"]').val()
            );
        }

        var join_date = get_date('joindate'),
           visit_date = get_date('lastvisit'),
        activity_date = get_date('lastactivity'),
            post_date = get_date('lastpost');

        var ret = {
            username  : html.find( '[name="user\\[username\\]"]'  ).val(),
            user_id   : user_id,
            email     : html.find( '[name="user\\[email\\]"]'     ).val(),
            ip        : html.find( '[name="user\\[ipaddress\\]"]' ).val(),
            homepage  : html.find( '[name="user\\[homepage\\]"]'  ).val(),
            signature : html.find( '[name="signature"]'           ).val(),
            post_count: parseInt( html.find( '[name="user\\[posts\\]"]').val(), 10 ),

            groups    : [primary_group].concat(additional_groups),

            image     : ( image ? image.replace( /^\.\./, '' ) : null ),

                join_date:               join_date.getTime(),
               visit_date: visit_date ? visit_date.getTime() : null,
            activity_date:           activity_date.getTime(),
                post_date:   post_date ? post_date.getTime() : null,

            pm_notification: (
                bb._config['unPMable user groups'].filter(function(group) { return group == primary_group }).length
                ?             { receive: false }                  // new users cannot receive messages
                : (
                    ( html.find('input[id^="rb_1_options\\[receivepm\\]"]').is(':checked') ) // receive PMs
                    ? (
                        ( html.find('input[id^="rb_1_options\\[emailonpm\\]"]').is(':checked') // notification e-mail
                        ? (
                            ( html.find('input[id^="rb_1_user\\[pmpopup\\]"]').is(':checked') ) // notification popup
                            ? { receive: true, notified: true , popup: true , email: true } // will receive a popup and an e-mail
                            : { receive: true, notified: true , popup: false, email: true } // will receive an e-mail
                        )
                        : ( html.find('input[id^="rb_1_user\\[pmpopup\\]"]').is(':checked') ) // notification popup
                            ? { receive: true, notified: true , popup: true , email: false } // will receive a popup
                            : { receive: true, notified: false, popup: false, email: false } // will receive messages, but won't receive notification
                        )
                    )
                    :         { receive: false }                  // cannot receive messages
                )
            )
        };

        ret.summary = (
            '(joined <time datetime="' +     join_date.toISOString() + '">' +     join_date.toISOString() + '</time>,' +
            ' active <time datetime="' + activity_date.toISOString() + '">' + activity_date.toISOString() + '</time>'
        );
        switch ( ret.post_count ) {
        case 0 :                                                  break;
        case 1 : ret.summary += ', 1 post'                      ; break;
        default: ret.summary += ', ' + ret.post_count + ' posts'; break;
        }
        var groups = ret.groups.filter(function(group) { return group != bb._config['default user group'] }).join(', ');
        if ( groups.length ) ret.summary += ', ' + groups;
        ret.summary += ')';

        return ret;
    });

}


/**
 * @summary Get posts for a user
 * @param {Number}  user_id       ID of user to get posts for
 * @param {Boolean} get_all_pages whether to download results on pages after the first
 * @return {jQuery.Promise}
 */
VBulletin.prototype.user_posts = function(user_id, get_all_pages) {
    var bb = this;
    function get_page(html, status, jqXHR) {
        var parse_date = bb.date_parser( html, jqXHR );
        return $(html).find('#searchbits > li').map(function() {
            return {
                date: parse_date( $('.postdate', this).text() ),

                username:           $('.username_container > a', this).text(),
                user_id : parseInt( $('.username_container > a', this).attr('href').split('?u=')[1], 10 ),

                post_id  : parseInt( this.id.substr(5), 10 ),
                thread_id: parseInt( $('h2 a', this).attr('href').split('?t=')[1], 10 ),
                thread_title: $('h2 a', this).text(),

                message: $.trim( $('blockquote', this).text() )
            };
        }).get();
    }
    return bb.get( '/search.php?do=finduser&userid=' + user_id + '&contenttype=vBForum_Post&showposts=1&pp=' + bb.default_reply_count ).then(
        get_all_pages ? function(html, status, jqXHR) {
            var match = /var RELPATH = "([^"]*)";[^]*<a href="javascript:\/\/" class="popupctrl">Page 1 of ([0-9]*)/.exec(html);
            if ( match ) {
                var ret = [ get_page( html, status, jqXHR ) ], requests = [];
                for ( var n=1; n!= match[2]; ++n )
                    (function(n) {
                        requests.push(
                            bb.get( match[1] + '&pp=' + bb.default_reply_count + '&page=' + (n+1) ).then(function(html, status, jqXHR) { ret[n] = get_page(html, status, jqXHR ) })
                        );
                    })(n);
                return $.when.apply( $, requests ).then(function() { return [].concat.apply( [], ret ) });
            } else {
                return get_page( html, status, jqXHR );
            }
        } : get_page
    );
}

/**
 * @summary Get user's signature user from ModCP
 * @param {Number} user_id ID of user to get signature for
 * @return {jQuery.Promise}
 */
VBulletin.prototype.user_signature_get = function(user_id) {
    var bb = this;
    return bb.get( '/modcp/user.php?do=editsig&u=' + user_id ).then(function(html) {

        if ( !this._modcp_data ) {
            // populate _modcp_data without making an extra request
            bb._modcp_data = $.Deferred().resolve(bb._parse_modcp_data(html)).promise();
        }

        return $(html).find('[name="signature"]').val();

    });
}

/**
 * @summary Set user's signature user through ModCP
 * @param {Number} user_id   ID of user to set signature for
 * @param {string} signature new signature text
 * @return {jQuery.Promise}
 */
VBulletin.prototype.user_signature_set = function(user_id, signature) {
    var bb = this;
    return this._get_modcp_data().then(function(data) {
        return bb.post( '/modcp/user.php?do=doeditsig', $.extend( {}, data, { do: 'doeditsig', signature: signature, userid: user_id } ) );
    });
}

/**
 * @summary Get new users
 * @return {jQuery.Promise}
 */
VBulletin.prototype.users_list_new = function() {

    return this.get( '/memberlist.php?order=desc&sort=joindate&pp=' + this.default_reply_count ).then(function(html) {
        return $(html).find('#memberlist_table tr:not(.columnsort) a.username').map(function() {
            var $this = $(this);
            return {
                username   : $this.text(),
                user_id    : parseInt( $this.attr('href').split('?u=')[1], 10 ),
                member_page: $this.attr('href')
            };
        }).get();
    });

}

/**
 * @summary suggest possible completions given a partial user name
 * @param {string} prefix partial user name
 * @return {Array.<Object>} list of user names and IDs
 */
VBulletin.prototype.users_complete = function( prefix ) {
    return this.post( '/ajax.php?do=usersearch', {
	do      : 'usersearch',
        fragment: prefix
    }).then(function(xml) {
        var ret = [], users = xml.getElementsByTagName('user'), n;
        for ( n=0; n!=users.length; ++n )
            ret.push({ user_id: parseInt( users[n].getAttribute('userid'), 10 ), username: users[n].textContent });
        return ret;
    });
}

/*
 * MISCELLANEOUS
 */

/**
 * @summary convert a URL to the parameters needed to build it
 * @param {string} url URL to convert
 * @return {Object} e.g. { type: 'user', url_for: 'user_show', args: { user_id: 1234 } }
 * @description The return value contains the following:
 * * type    - the main object of the URL (e.g. "user" or "thread")
 * * subtype - the specific page type (see url_for for a list)
 * * args    - arguments to url_for.<subtype>()
 */
VBulletin.prototype.url_decode = function( url ) {
    var ret;
    var decoders = [
        [ /showthread\.php\?(?:.*&)?p=([0-9]+)/, function(url, id) { ret = { type: 'post'  , subtype: 'thread_show', args: {   post_id: id } } } ],
        [ /showthread\.php\?(?:.*&)?t=([0-9]+)/, function(url, id) { ret = { type: 'thread', subtype: 'thread_show', args: { thread_id: id } } } ],
        [ /member.php\?(?:.*&)?u=([0-9]+)/     , function(url, id) { ret = { type: 'user'  , subtype:   'user_show', args: {   user_id: id } } } ]
    ];
    do {
        url.replace.apply( url, decoders.shift() );
    } while ( decoders.length && !ret );
    return ret;
}

/**
 * @summary Ban a spambot and delete all their posts as spam
 * @param {Number} user_ID ID of the user to ban
 * @param {Number} post_ID ID of the post that made you realise this was a spambot
 * @return {jQuery.Promise}
 */
VBulletin.prototype.spammer_delete = function( user_id, post_id ) {
    return this.post(
        '/inlinemod.php?do=dodeletespam',
        {
            do             : 'dodeletespam',
	    'userid[]'     : user_id,
	    usergroupid    : 22,
	    period         : 'PERMANENT',
	    reason         : 'Spambot',
	    sbutton        : 'Ban User',
	    p              : post_id,
	    postids        : post_id,
	    useraction     : 'ban',
	    deleteother    : 1,
	    deletetype     : 1,
	    deletereason   : 'Spambot',
	    keepattachments: 0,
	    report         : 1,
	    type           : 'post'
        }
    );
}

// code shared between forum_metadata() and thread_metadata():
VBulletin.prototype._forum_thread_metadata = function(html) {
    html = $(html);
    var forum = html.find('.navbit a[href^="forumdisplay.php"]').map(function() {
        return { forum_id: parseInt( this.href.split( /\?f=/ )[1], 10 ), name: this.textContent };
    }).get();
    var prefix_id, prefixes = html.find('#prefix option').map(function() {
        if ( $(this).is(':checked') ) prefix_id = this.value;
        return {
            prefix_id: this.value, // despite the name, this is a string
            text: this.textContent
        };
    });
    var icon_id, icons = html.find('[name="iconid"]').map(function() {
        if ( $(this).is(':checked') ) icon_id = parseInt( this.value, 10 );
        return {
            icon_id: parseInt( this.value, 10 ),
            file: html.find('label[for="'+this.id+'"] img').attr('src'),
            name: html.find('label[for="'+this.id+'"] img').attr('alt')
        };
    });
    return {
        forum_id: forum.length ? forum[forum.length-1].forum_id : null,
        forum: forum,

        prefix_id: prefix_id,
        prefixes: prefixes.get(),

        icon_id: icon_id,
        icons: icons.get()

    };
}

/**
 * @summary Get metadata about a forum
 * @param {Number} forum_id
 * @return {jQuery.Promise}
 */
VBulletin.prototype.forum_metadata = function( forum_id ) {
    return this.get( '/newthread.php?do=newthread&f=' + forum_id ).then(this._forum_thread_metadata);
}

/**
 * @summary Get information about threads from the first page of a forum
 * @see {thread_posts}
 *
 * @param {number}  forum_id ID of forum to get threads for
 * @param {Boolean} recent   only download recently-changed threads (usually the past 24 hours)
 * @return {jQuery.Promise} Deferred object that will return when all pages have loaded
 */
VBulletin.prototype.forum_threads = function(forum_id, recent) {

    var bb = this;

    return bb.get(
        recent
        ? '/forumdisplay.php?pp=' + bb.default_reply_count + '&sort=lastpost&order=desc&daysprune=1&f=' + forum_id
        : '/forumdisplay.php?pp=' + bb.default_reply_count + '&sort=lastpost&order=desc&daysprune=-1&f=' + forum_id
    ).then(function(html) {

        var is_moderator = html.search( '<script type="text/javascript" src="clientscript/vbulletin_inlinemod.js?' ) != -1;

        var today = new Date(), yesterday = new Date();
        yesterday.setDate(yesterday.getDate()-1);
        today     = [ today    .getDate().toString().replace(/^(.)$/,"0$1"), (today    .getMonth()+1).toString().replace(/^(.)$/,"0$1"), today    .getYear()+1900 ].join( '/' );
        yesterday = [ yesterday.getDate().toString().replace(/^(.)$/,"0$1"), (yesterday.getMonth()+1).toString().replace(/^(.)$/,"0$1"), yesterday.getYear()+1900 ].join( '/' );

        if ( is_moderator && !this._forum_threads_callback_initialised++ ) {
            bb.doc.on( 'dblclick', '.bb_api_threadstatus', function() {
                var threadbit = $(this).closest('.threadbit');
                bb.thread_openclose( $(this).data( 'thread_id' ), threadbit.hasClass('lock') )
                    .done(function() { threadbit.toggleClass('lock') });
            });
        }

        return $(html).find('li.threadbit').map(function() {
            var title = $('a.title', this);
            var thread_id = title.attr('href').split( '?t=' )[1];

            if ( is_moderator ) {
                $('.threadstatus', this)
                    .addClass( 'bb_api_threadstatus' )
                    .data( 'thread_id', thread_id )
                    .css({ cursor: 'pointer' })
                    .attr( 'title', 'double-click to close this thread' )
            }

            var understate_text = $.trim($('.prefix.understate', this).text());
            var status = 'open';
            if      ( understate_text.search( /^Closed:/ ) == 0 ) status = 'closed';
            else if ( understate_text.search( /^Moved:/  ) == 0 ) status = 'moved';
            else if ( $( '.prefix_closed'          , this ).length ) status = 'closed'
            else if ( $( '.prefix_deleted,.deleted', this ).length ) status = 'deleted'
            ;

            var ret = {
                container_element: this,
                forum_id         : forum_id,
                thread_id        : parseInt(thread_id,10),
                orig_thread_id   : parseInt(this.id.substr(7),10), // for moved threads
                title_element    : title,
                title            : title.text(),
                status           : status,
                is_sticky        : $('div.sticky', this).length ? true : false
            };
            if ( status != 'moved' && status != 'deleted' ) {
                ret = $.extend( ret, {
                    last_post_id : parseInt( $( 'a.lastpostdate', this ).attr('href').split('#post')[1] ),
                    reply_count  : parseInt( $('.threadstats a', this).text(), 10 ),
                    last_modified: $.trim($('a.lastpostdate', this).parent().text().replace('Today',today).replace('Yesterday',yesterday))
                });
            }
            return ret;
        }).get();

    });

}

/**
 * @summary Get a tree of forums on the site
 * @return {jQuery.Promise} Deferred object that will return when all pages have loaded
 */
VBulletin.prototype.forums = function() {
    return this.get( '/archive/index.php' ).then(function(html) {
        function node() {
            var children = $(this).children('ul').children().map(node).get();
            var a = $(this).children('a');
            var forum_id;
            ( a.attr('href') || '' ).replace( /\/archive\/index.php\/f-([0-9]+)\.html$/, function(match, _forum_id) { forum_id = _forum_id });
            if ( children.length || forum_id ) {
                var ret = { name: a.text() };
                if ( forum_id ) ret.forum_id = parseInt( forum_id, 10 );
                if ( children.length ) ret.children = children;
                return ret;
            }
        }
        return $(html).find( '#content > ul > li' ).map(node).get();
    });
}

/**
 * @summary get recent threads/posts on the forum
 * @return {jQuery.Promise}
 */
VBulletin.prototype.activity = function(min_date, min_post_id) {
    return this.post(
        '/activity.php',
        {
            mindateline: Math.max( min_date, Math.floor( new Date().getTime()/1000 - 60*60 ) ),
            minid      : min_post_id || 1,
            minscore   : 0,
            pp         : this.default_reply_count,
            show       : 'all',
            sortby     : 'recent',
            time       : 'anytime'
        }
    ).then(function(xml) {
        var posts = [], elements = xml.getElementsByTagName('bit');
        var max_post_id = 0, max_thread_id = 0;
        for ( var n=0; n!=elements.length; ++n ) {
            var $element = $(elements[n].textContent);
            var links = $element.find('a');
            var   post_id = parseInt( links.last().attr('href').split('#post')[1], 10 );
            var thread_id = parseInt( links.eq(1) .attr('href').split('?t='  )[1], 10 );
            if ( post_id ) {
                if ( max_post_id   <   post_id ) max_post_id   =   post_id;
            } else {
                if ( max_thread_id < thread_id ) max_thread_id = thread_id;
            }
            posts.push({
                container_element: $element,
                  post_id        :   post_id,
                thread_id        : thread_id,
                 forum_id        : parseInt( links.eq(2) .attr('href').split('?f='  )[1], 10 ),
                date             : $element.find('.date').text(),
                username         : links.first().text(),
                user_id          : parseInt( ( links.first().attr('href') || '             guest' ).substr(13), 10 ),
                title            : links.eq(1).text(),
                message          : $element.find('.excerpt').text(),
                message_element  : $element.find('.excerpt'),
            });
        }
        return {
            max_date     : parseInt( xml.getElementsByTagName('maxdateline')[0].textContent, 10 ),
            max_post_id  : max_post_id,
            max_thread_id: max_thread_id,
            posts: posts
        }
    });
}

/**
 * @summary add CSS for different page types to the current page
 * @param {Array.<string>} page_types types of page to add CSS for
 *
 * @description Sometimes we want to insert content that resembles one
 * page on a different type of page (e.g the dashboard uses content
 * from several pages).  As vBulletin uses different CSS on different
 * pages, we need to include the extra files by hand.
 */
VBulletin.prototype.css_add = function(page_types) {

    var sheet_names = {
         forum_show: 'threadlist.css',
          user_show: 'member.css',
        thread_show: 'postbit.css',
        activity   : 'activitystream.css',
        folder_show: 'private.css'
    };

    var extra_types = [];
    page_types.forEach(function(page_type) {
        if ( sheet_names.hasOwnProperty(page_type) ) extra_types.push(sheet_names[page_type])
    });

    if ( extra_types.length ) {
        var stylesheet = $('link[rel="stylesheet"][type="text/css"]').first();
        stylesheet
            .clone()
            .attr( 'href', stylesheet.attr( 'href' ).replace( /([?&]sheet)=[^&]*/, '$1=' + extra_types.join() ) )
            .insertBefore(stylesheet)
        ;
        // Fix conflicts created by adding the above CSS:
        $("head").append(
            "<style type='text/css'>" +
                '#above_postlist { top: 0 }' +
                '.threadbit.attachments { padding: 0 }' +
            "</style>"
        );
    }

}

/**
 * @summary Get keys for use in building dynamic CSS
 * @return {Object.<string,string>} CSS keys and values
 *
 * @description Bulletin board software often uses theme-specific values
 * to control various bits of CSS.  We retrieve those from the page,
 * in a format compatible with Variables.parse()
 */
VBulletin.prototype.css_keys = function() {
    var ret = {}, element;
    element = $('<div></div>').appendTo(this.doc.find('body'));
    ret['foreground colour'] =  element.css( 'color' );
    element.remove();

    element = $('<div class="body_wrapper"></div>').appendTo(this.doc.find('body'));
    ret['body_wrapper background colour'] = element.css('background-color');
    element.remove();

    element = $('<div class="popupmenu"><a class="popupctrl"></a></div>').appendTo(this.doc.find('body'));
    element.children().css('background-image').replace(/(?:^|\/)images\.([^\/]*)/, function(image, theme) { ret.theme = theme });
    element.remove();

    return ret;
}

/**
 * @summary Get posts in the moderation queue
 * @return {jQuery.Promise}
 */
VBulletin.prototype.posts_moderated = function() {

    return this.get( '/modcp/moderate.php?do=posts' ).then(function(html) {
        html = $(html);
        var ret = {
            threads: [],
            posts  : [],
        };
        var current_block, current;
        function parse_row() {
            $( 'a[href^="user.php"]', this ).each(function() { // User who created a post (first row of a block)
                current_block.push(current = {
                    user_id : parseInt( this.href.split('&u=')[1], 10 ),
                    username: $(this).text()
                });
            });
            $( 'a[href^="../showthread.php"]', this ).each(function() { // Thread (in post block)
                current.thread_id    = parseInt( this.href.split('?t=')[1], 10 );
                current.thread_title = $(this).text();
            });
            $( 'a[href^="../forumdisplay.php"]', this ).each(function() { // Forum
                current.forum_id   = parseInt( this.href.split('?f=')[1], 10 );
                current.forum_name = $(this).text();
            });
            $( '[name^="threadtitle"]', this ).each(function() { // Title (in thread block)
                current.thread_id    = parseInt( this.name.split(/[\[\]]/)[1], 10 );
                current.thread_title = $(this).val();
            });
            $( '[name^="posttitle"]', this ).each(function() { // Title (in post block)
                current.post_id    = parseInt( this.name.split(/[\[\]]/)[1], 10 );
                current.post_title = $(this).text();
            });
            $( '[name^="threadpagetext"],[name^="postpagetext"]', this ).each(function() { // Body
                current.bbcode = $(this).val();
            });
            $( '[name^="threadnotes"]', this ).each(function() { // Thread notes (in thread block)
                current.notes = $(this).val();
            });
        }
        current_block = ret.threads; html.find('#threads tr').each(parse_row);
        current_block = ret.posts  ; html.find('#posts tr'  ).each(parse_row);

        return ret;
    });

}

/**
 * @summary log in to ModCP and get a URL
 * @param {jQuery} iframe iframe element to use
 * @param {string} url URL to get
 * @param {string=} page_top_selector selector that indicates the top of the final page (default: '#cpform')
 * @param {string=} page_bottom_selector selector that indicates the bottom of the final page (default: '.copyright')
 * @param {jQuery.Promise}
 *
 * @description
 * ModCP authentication is unrelated to normal site authentication,
 * and it's easy to get logged out of one but stay in the other.
 * This function pops up an iframe with login details if necessary.
 */
VBulletin.prototype.moderation_page = function( iframe, url, page_top_selector, page_bottom_selector ) {

    var dfd = $.Deferred();
    var title = document.title; // iframes tend to overwrite the document title

    var login_attempt_count = 0;

    iframe
        .css({ overflow: 'hidden', display: 'none' })
        .attr( 'src', url )
        .one( 'load', function() {
            document.title = title;
            if ( $('#vb_login_username', iframe[0].contentDocument.body ).length ) { // need to log in
                setTimeout(function() {
                    if ( !login_attempt_count++ && iframe[0].contentDocument.getElementById('vb_login_password').value.length ) {
                        $(iframe[0].contentDocument.body).find('form').submit();
                    } else {
                        $(iframe[0].contentDocument.body).css({ overflow: 'hidden' }).find('p').remove();
                        iframe.css({ display: 'block', width: '450px', height: '200px' }); // set the desired size
                        var form = $(iframe[0].contentDocument.body).find('form');
                        iframe.css({ width: form.outerWidth() + 'px', height: form.outerHeight() + 'px' }); // fit based on the computed size
                    }
                    var has_progressed = false;
                    var interval = setInterval(function() {
                        if ( $('#vb_login_username', iframe[0].contentDocument.body ).length ) {
                            // still on the login page
                        } else if ( $('#redirect_button,#vb_login_username,.standard_error', iframe[0].contentDocument.body).length && !has_progressed ) {
                            // on the redirect page
                            has_progressed = true;
                            iframe.hide();
                            dfd.notify();
                        } else if ( $( page_top_selector || '#cpform', iframe[0].contentDocument.body ).length ) {
                            // on the user page
                            iframe.hide();
                            clearInterval(interval);
                            if ( !has_progressed ) dfd.notify();
                            document.title = title;
                            interval = setInterval(function() {
                                if ( $( page_bottom_selector || '.copyright', iframe[0].contentDocument.body ).length ) {
                                    dfd.resolve(iframe[0]);
                                    clearInterval(interval);
                                }
                            }, 50);
                        } // else page is loading
                    }, 50);
                }, 0 );
            } else { // already logged in
                dfd.notify();
                document.title = title;
                dfd.resolve(iframe[0]);
                return;
            }
        });

    return dfd.promise();

}

/**
 * @summary log in to the site without user interaction
 * @param {String} username account to log in as
 * @param {String} password account password
 * @param {jQuery.Promise}
 *
 * @description
 * This function logs in automatically.  If you need the user to type the password,
 * use .login() instead.
 */
VBulletin.prototype.login_auto = function( username, password ) {
    var bb = this;
    return bb.get( '/faq.php' ).then(function(html) { // get any page, to check if we're logged in
        bb.default_user = username;
        if ( /var SECURITYTOKEN = "guest";/.test(html) ) { // not logged in
            return bb.post(
                '/login.php?do=login',
                {
                    do: 'login',
                    vb_login_username: username,
                    vb_login_password: password
                }
            ).then(function(html) {
                if ( bb._origin ) {
                    html.replace( /var SECURITYTOKEN = "([^"]*)";/                                       , function( match, t ) { bb.security_token = t });
                    $(html).find('#redirect_button a.textcontrol').attr('href').replace( /[\?&]s=([^&]+)/, function( match, s ) { bb.session_id     = s });
                    BabelExt.memoryStorage.set( 'VBulletin login ' + bb._origin + ' ' + username, JSON.stringify({
                        creation_time : new Date().getTime(),
                        session_id    : bb.session_id,
                        security_token: bb.security_token
                    }));
                }
            });
        }
    });
}

/**
 * @summary log in to the site
 * @param {jQuery} iframe       iframe element to use
 * @param {String} default_user account to log in as
 * @param {String} hint         hint to show the user
 * @param {jQuery.Promise}
 *
 * @description
 * This function pops up an iframe with login details for the main site.
 * Note: for security reasons, the calling code must ensure that
 * VBulletin.iframe_callbacks() is called in the iframe page.
 * (this could be done automatically when logging in to the current site,
 * but not when logging in to another domain)
 */
VBulletin.prototype.login = function( iframe, default_user, hint ) {

    var dfd = $.Deferred();
    var title = document.title; // iframes tend to overwrite the document title

    if ( !this._origin ) {
        dfd.resolve(iframe);
        return dfd.promise();
    }

    var bb = this;

    function get_login() {
        iframe
            .css({ overflow: 'hidden', display: 'none' })
            .attr( 'src', bb._origin )
        ;

        window.addEventListener(
            "message",
            function listen(event) {
                if (event.origin !== bb._origin) return;
                if ( event.data == 'BulletinBoard VBulletin get default user' ) {
                    document.title = title;
                    iframe.css({ display: 'block', width: '0', height: '0', border: 'none' });
                    setTimeout(function() {
                        // Firefox will silently refuse to reply unless we wait a little while
                        event.source.postMessage( 'BulletinBoard VBulletin default user ' + (default_user||''), event.origin );
                    }, 10 );
                } else if ( event.data == 'BulletinBoard VBulletin get hint' ) {
                    setTimeout(function() {
                        // Firefox will silently refuse to reply unless we wait a little while
                        event.source.postMessage( 'BulletinBoard VBulletin hint ' + (hint||''), event.origin );
                    }, 10 );
                } else if ( event.data == 'BulletinBoard VBulletin show' ) {
                    dfd.notify('show');
                    iframe.css({ display: 'block', width: '450px', height: '2em' });
                } else if ( event.data == 'BulletinBoard VBulletin progress' ) {
                    iframe.hide();
                } else {
                    event.data.replace( /^BulletinBoard VBulletin session (.*)/, function(match, session_id) { bb.session_id = session_id });
                    event.data.replace( /^BulletinBoard VBulletin success (.*)\n([^]*)/, function(match, security_token, doc) {
                        bb.security_token = security_token;
                        bb.default_user   = default_user;
                        bb.doc = $(doc);
                        document.title = title;
                        iframe.hide();
                        window.removeEventListener("message", listen, false);
                        if ( bb.session_id ) {
                            BabelExt.memoryStorage.set( 'VBulletin login ' + bb._origin + ' ' + default_user, JSON.stringify({
                                creation_time : new Date().getTime(),
                                session_id    : bb.session_id,
                                security_token: bb.security_token
                            }));
                        }
                        dfd.resolve(iframe);
                    });
                }
            },
            false
        );

    }

    if ( default_user ) {
        BabelExt.memoryStorage.get( 'VBulletin login ' + bb._origin + ' ' + default_user, function(data) {
            if ( data.value ) {
                var credentials = JSON.parse(data.value);
                if ( credentials.creation_time + 60*60*1000 > new Date().getTime() ) {
                    bb.session_id     = credentials.session_id;
                    bb.security_token = credentials.security_token;
                    dfd.resolve(iframe);
                } else {
                    get_login();
                }
            } else {
                get_login();
            }
        })
    } else {
        get_login();
    }

    return dfd.promise();

}

/**
 * @summary communicate from an iframe to the parent window
 * @param {string} target_origin expected origin of the parent window
 * @param {Array.<string>} cookie_domains domains that cookies are normally set for (default: do not delete cookies)
 *
 * @description For security reasons, .login() cannot inject JavaScript
 * into iframes in different origins.  Instead, the calling code must
 * arrange for this function to be called.
 *
 * It's sometimes useful to use VBulletin's no-cookie fallback mode
 * (e.g. using the "trailing dot" domain on a site that hard-codes
 * the cookie domain).  Passing cookie_domain ensures cookies are deleted
 * to resemble a cookieless browser
 */
VBulletin.iframe_callbacks = function(target_origin, cookie_domains) {
    BabelExt.utils.dispatch(
        {
            match_pathname: '/login.php',
            match_params: { do: 'login' },
            callback: function( stash, pathname, params, a ) {
                window.parent.postMessage( 'BulletinBoard VBulletin progress', target_origin );
            }
        },
        {
            match_pathname: '/login.php',
            match_params: { do: 'login' },
            match_elements: '#redirect_button a.textcontrol',
            callback: function( stash, pathname, params, a ) {
                a.href.replace( /[\?&]s=([^&]+)/, function( match, session_id ) {
                    window.parent.postMessage( 'BulletinBoard VBulletin session ' + session_id, target_origin );
                });
            }
        },
        {
            match_elements: 'input[name="securitytoken"]',
            callback: function( stash, pathname, params, input) {
                if ( input.value == 'guest' ) $(function() {
                    window.addEventListener("message", function(event) {
                        if (event.origin !== target_origin) return;
                        event.data.replace( /^BulletinBoard VBulletin default user (.*)/, function(match, default_user) {
                            if ( default_user.length ) $('#navbar_username').val( default_user );
                            setTimeout(function() {
                                if ( document.getElementById('navbar_password').value.length )
                                    $('.loginbutton').click();
                                else
                                    window.parent.postMessage( 'BulletinBoard VBulletin show', target_origin );
                            }, 50 );
                        });
                        event.data.replace( /^BulletinBoard VBulletin hint (.*)/, function(match, hint) {
                            $(hint).insertAfter('#navbar_password');
                        });
                    }, false);
                    window.parent.postMessage( 'BulletinBoard VBulletin get default user', target_origin );
                    window.parent.postMessage( 'BulletinBoard VBulletin get hint', target_origin );
                    $(document.body)
                        .css({ overflow: 'hidden' }).html( $('#navbar_loginform')[0].outerHTML )
                        .children().css({ 'text-align': 'center', 'width': '450px' });
                    $('#navbar_password_hint,#remember').hide();
                    $('#navbar_password').show().focus().attr( 'placeholder', 'password' );
                    $('.loginbutton').click(function() {
                        document.cookie.split(/; /).forEach(function(cookie) {
                            ( cookie_domains || [] ).forEach(function(cookie_domain) {
                                document.cookie = cookie.replace(/($|=.*)/, '=; domain='+cookie_domain+'; expires=Thu, 01-Jan-1970 00:00:01 GMT');
                            });
                        });
                    });
                });
                else {
                    window.parent.postMessage( 'BulletinBoard VBulletin success ' + input.value + "\n" + document.documentElement.outerHTML, target_origin );
                }
            }
        }
    );
}

/**
 * @summary set the contents of the page's main edit box
 * @param {string} text text to set
 */
VBulletin.prototype.editor_set = function( text ) {
    $('#vB_Editor_QR_editor_backup,#vB_Editor_QR_editor').val( text );
    if ( ! $('#vB_Editor_001_textarea:visible,#cke_contents_vB_Editor_QR_editor:visible,.cke_source:visible').val( text ).length )
        BabelExt.utils.runInEmbeddedPage("vB_Editor['vB_Editor_001'].write_editor_contents(" + JSON.stringify(text) + ")");
}

/**
 * @summary get the contents of the page's main edit box
 * @return {string} text contents
 */
VBulletin.prototype.editor_get = function() {
    // The only reliable way to get this value is from JavaScript running in page context:
    BabelExt.utils.runInEmbeddedPage( 'document.body.setAttribute( "data-editor-contents", vB_Editor["vB_Editor_001"].get_editor_contents() )');
    var ret = this.doc.find('body').attr('data-editor-contents');
    this.doc.find('body').removeAttr('data-editor-contents');
    return ret;
}

/**
 * @summary Statistics about the server running the bulletin board
 * @return {Object} one, five and fifteen minute load averages; total, logged-in and logged-out users online
 */
VBulletin.prototype.server_stats = function( ) {
    return this.get( '/modcp/index.php?do=home' ).then(function(html) {
        var ret;
        $(html).find('.alt1').eq(1).text().replace(
            /^\s*([0-9.]+)\s*([0-9.]+)\s*([0-9.]+)\s*\|\s*([0-9,]+) Users Online \(([0-9,]+) members and ([0-9,]+) guests\)\s*$/,
            function( match, one_minute, five_minutes, fifteen_minutes, total_online, members_online, guests_online ) {
                ret = {
                        one_minute_loadavg: parseFloat(     one_minute  ),
                       five_minute_loadavg: parseFloat(    five_minutes ),
                    fifteen_minute_loadavg: parseFloat( fifteen_minutes ),
                      total_online: parseInt(   total_online.replace( /,/g, '' ), 10 ),
                    members_online: parseInt( members_online.replace( /,/g, '' ), 10 ),
                     guests_online: parseInt(  guests_online.replace( /,/g, '' ), 10 ),
                };
            });
        return ret;
    });
}

/**
 * @summary Redirect from the "moderation_ipsearch" page to the right page
 * @param {Object} params page parameters
 * @description We would like to link to the IP search page, but it needs
 * various extra parameters for authentication and CSRF protection.
 * This function redirects from the fake page to the real one.
 */
VBulletin.prototype.redirect_modcp_ipsearch = function(params) {
    var bb = this;
    bb.get( '/modcp/user.php?do=doips' ).then(function(html) {
        function redirect() {
            var form = $(html).find('#cpform').hide().appendTo(bb.doc.find('body'));
            [ 'ipaddress', 'username', 'depth' ].forEach(function(param) {
                if ( params.hasOwnProperty(param) ) form.find( '[name="' + param + '"]' ).val( params[param] );
            });
            form.submit();
        }
        if ( document.body ) redirect();
        else $(redirect);
    });
    $(function() {
        $('blockquote').text( 'Redirecting...' );
    });
}

/**
 * @summary build a function to parse dates on a page
 * @param {string} html  page contents
 * @param {jqXHR}  jqXHR page XMLHttpRequest (to get the unambiguous server date)
 * @return {Date} date
 * @description VBulletin often returns dates relative to "now", which depends on
 * time zones, server error etc.  To reliably parse dates, we need to get the server
 * time from the HTTP headers.
 */
VBulletin.prototype.date_parser = function( html, jqXHR ) {

    var timezone = 0;
    html.replace( /<div id="footer_time" class="shade footer_time">All times are GMT ([-+])([0-9]+)/, function( match, plusminus, amount ) {
        timezone = parseInt( amount, 10 ) * 1000*60*60;
        if ( plusminus == '-' ) timezone *= -1;
    });

    var today = new Date( jqXHR.getResponseHeader('Date') ).getTime() - timezone;
    today = today - ( today % (1000*60*60*24) ) - timezone;

    return function(time) {
        var ret = null;

        // dates of the form "Today, 12:34PM", "Today 12:34 PM" etc.:
        time.replace( /^\s*([^, ]+?),?\s*([0-9]+):([0-9]+)\s*([AP])M/, function( match, date, hour, minute, ap ) {
            switch ( date ) {
            case 'Today'    : ret = today                ; break;
            case 'Yesterday': ret = today - 1000*60*60*24; break;
            default:
                ret = new Date( date.split('/').reverse().join('-') ).getTime() - timezone;
            }
            ret = new Date( ret + ( parseInt( hour, 10 ) * 60 + (ap=='P' ? 12*60 : 0) + parseInt( minute, 10 ) ) * 60000 );
        });
        if ( ret ) return ret;

        // Dates of the form "Today":
        time.replace( /\b(?:Today|Yesterday|([0-9][0-9])\/([0-9][0-9])\/([0-9][0-9][0-9][0-9]))\b/, function( date, day, month, year ) {
            switch ( date ) {
            case 'Today'    : ret = today                ; return;
            case 'Yesterday': ret = today - 1000*60*60*24; return;
            default:
                ret = new Date( [ year, month, day ].join('-') ).getTime() - timezone;
            }
            ret = new Date( ret );
        });
        return ret;
    }

}