(function() {


/**
 * A mapping of available image scaling factors to the associated label.
 */
const scalingFactors = new Map([
    [0.33, '33%'],
    [0.5, '50%'],
    [1.0, '100%'],
    [2.0, '200%'],
]);


/**
 * Base class for providing a view onto an image or diff of images.
 *
 * This handles the common functionality, such as loading images, determining
 * the image region, rendering, and so on.
 *
 * Subclasses must, at a minimum, provide a 'mode', 'name', and must set
 * $commentRegion to an element representing the region where comments are
 * allowed.
 */
const BaseImageView = Backbone.View.extend({
    template: null,
    mode: null,
    name: null,

    /**
     * Initialize the view.
     */
    initialize() {
        this.$commentRegion = null;

        this.listenTo(this.model,
                      'change:scale',
                      (model, scale) => this._onScaleChanged(scale));
    },

    /**
     * Compute a CSS class name for this view.
     *
     * Returns:
     *     string:
     *     A class name based on the current mode.
     */
    className() {
        return `image-diff-${this.mode}`;
    },

    /**
     * Load a list of images.
     *
     * Once each image is loaded, the view's onImagesLoaded function will
     * be called. Subclasses can override this to provide functionality based
     * on image sizes and content.
     *
     * Args:
     *     $images (jQuery):
     *         The image elements to load.
     */
    loadImages($images) {
        const scale = this.model.get('scale');

        let loadsRemaining = $images.length;

        this._$images = $images;

        $images.each((ix, image) => {
            const $image = $(image);

            if ($image.data('initial-width') === undefined) {
                image.onload = () => {
                    loadsRemaining--;
                    console.assert(loadsRemaining >= 0);

                    $image
                        .data({
                            'initial-width': image.width,
                            'initial-height': image.height,
                        })
                        .css({
                            width: image.width * scale,
                            height: image.height * scale,
                        });

                    if (loadsRemaining === 0) {
                        this.onImagesLoaded();
                        this.trigger('regionChanged');
                    }
                };
            } else {
                loadsRemaining--;
                if (loadsRemaining === 0) {
                    this.onImagesLoaded();
                    this.trigger('regionChanged');
                }
            }
        });
    },

    /**
     * Return the region of the view where commenting can take place.
     *
     * The region is based on the $commentRegion member variable, which must
     * be set by a subclass.
     *
     * Returns:
     *     object:
     *     An object with ``left``, ``top``, ``width``, and ``height`` keys.
     */
    getSelectionRegion() {
        const $region = this.$commentRegion;
        const offset = $region.position();

        /*
         * The margin: 0 auto means that position.left() will return
         * the left-most part of the entire block, rather than the actual
         * position of the region on Chrome. Every other browser returns 0
         * for this margin, as we'd expect. So, just play it safe and
         * offset by the margin-left. (Bug #1050)
         */
        offset.left += $region.getExtents('m', 'l');

        return {
            left: offset.left,
            top: offset.top,
            width: $region.width(),
            height: $region.height()
        };
    },

    /**
     * Callback handler for when the images on the view are loaded.
     *
     * Subclasses that override this method must call the base method so that
     * images can be scaled appropriately.
     */
    onImagesLoaded() {
        let scale = null;

        /*
         * If the image is obviously a 2x or 3x pixel ratio, pre-select the
         * right scaling factor.
         *
         * Otherwise, we select the largest scaling factor that allows the
         * entire image to be shown (or the smallest scaling factor if the
         * scaled image is still too large).
         */
        const filename = this.model.get('filename');

        /*
         * The `filename` attribute does not exist for screenshots, so we need
         * to check it.
         */
        if (filename) {
            if (filename.includes('@2x.')) {
                scale = 0.5;
            } else if (filename.includes('@3x.')) {
                scale = 0.33;
            }
        }

        if (scale === null) {
            const {width} = this.getInitialSize();
            const maxWidth = this.$el.closest('.image-content').width();
            const scales = Array
                .from(scalingFactors.keys())
                .filter(f => (f <= 1));

            for (let i = scales.length - 1; i >= 0; i--) {
                scale = scales[i];

                if (width * scale <= maxWidth) {
                    break;
                }
            }
        }

        this.model.set('scale', scale);
    },

    /**
     * Handle the image scale being changed.
     *
     * Args:
     *     scale (number):
     *         The new image scaling factor (where 1.0 is 100%, 0.5 is 50%,
     *         etc).
     */
    _onScaleChanged(scale) {
        this._$images.each((index, el) => {
            const $image = $(el);

            $image.css({
                width: $image.data('initial-width') * scale,
                height: $image.data('initial-height') * scale,
            });
        });
    },

    /**
     * Return the initial size of the image.
     *
     * Subclasses must override this.
     *
     * Returns:
     *     object:
     *     An object containing the initial height and width of the image.
     */
    getInitialSize() {
        console.assert(
            false, 'subclass of BaseImageView must implement getInitialSize');
    },
});


/**
 * Displays a single image.
 *
 * This view is used for standalone images, without diffs. It displays the
 * image and allows it to be commented on.
 */
const ImageAttachmentView = BaseImageView.extend({
    mode: 'attachment',
    tagName: 'img',

    /**
     * Render the view.
     *
     * Returns:
     *     ImageAttachmentView:
     *     This object, for chaining.
     */
    render() {
        this.$el.attr({
            title: this.model.get('caption'),
            src: this.model.get('imageURL')
        });

        this.$commentRegion = this.$el;

        this.loadImages(this.$el);

        return this;
    },

    /**
     * Return the initial size of the image.
     *
     * Subclasses must override this.
     *
     * Returns:
     *     object:
     *     An object containing the initial height and width of the image.
     */
    getInitialSize() {
        const $img = this._$images.eq(0);

        return {
            width: $img.data('initial-width'),
            height: $img.height('initial-height'),
        };
    },
});


/**
 * Displays a color difference view of two images.
 *
 * Each pixel in common between images will be shown in black. Added pixels
 * in the new image are shown as they would in the image itself. Differences
 * in pixel values are shown as well.
 *
 * See:
 * http://jeffkreeftmeijer.com/2011/comparing-images-and-creating-image-diffs/
 */
const ImageDifferenceDiffView = BaseImageView.extend({
    mode: 'difference',
    name: gettext('Difference'),

    template: _.template([
        '<div class="image-container">',
        ' <canvas></canvas>',
        '</div>'
    ].join('')),

    /**
     * Initialize the view.
     */
    initialize() {
        _super(this).initialize.call(this);

        this._origImage = null;
        this._modifiedImage = null;
    },

    /**
     * Render the view.
     *
     * Image elements representing the original and modified images will be
     * created and loaded. After loading, onImagesLoaded will handle populating
     * the canvas with the difference view.
     *
     * Returns:
     *     ImageDifferenceDiffView:
     *     This object, for chaining.
     */
    render() {
        this.$el.html(this.template(this.model.attributes));

        this.$commentRegion = this.$('canvas');
        this._$canvas = this.$commentRegion;

        this._origImage = new Image();
        this._origImage.src = this.model.get('diffAgainstImageURL');

        this._modifiedImage = new Image();
        this._modifiedImage.src = this.model.get('imageURL');

        this.loadImages($([this._origImage, this._modifiedImage]));

        return this;
    },

    /**
     * Render the difference between two images onto the canvas.
     */
    onImagesLoaded() {
        const origImage = this._origImage;
        const modifiedImage = this._modifiedImage;
        const scale = this.model.get('scale');

        this._maxWidth = Math.max(origImage.width, modifiedImage.width);
        this._maxHeight = Math.max(origImage.height, modifiedImage.height);

        _super(this).onImagesLoaded.call(this);

        this._$canvas
            .attr({
                width: this._maxWidth,
                height: this._maxHeight
            })
            .css({
                width: this._maxWidth * scale + 'px',
                height: this._maxHeight * scale + 'px'
            });

        const $modifiedCanvas = $('<canvas/>')
            .attr({
                width: this._maxWidth,
                height: this._maxHeight
            });

        const origContext = this._$canvas[0].getContext('2d');
        origContext.drawImage(origImage, 0, 0);

        const modifiedContext = $modifiedCanvas[0].getContext('2d');
        modifiedContext.drawImage(modifiedImage, 0, 0);

        const origImageData = origContext.getImageData(
            0, 0, this._maxWidth, this._maxHeight);
        const origPixels = origImageData.data;

        const modifiedPixels = modifiedContext.getImageData(
            0, 0, this._maxWidth, this._maxHeight).data;

        for (let i = 0; i < origPixels.length; i += 4) {
            origPixels[i] += modifiedPixels[i] -
                             2 * Math.min(origPixels[i], modifiedPixels[i]);
            origPixels[i + 1] += modifiedPixels[i + 1] -
                                 2 * Math.min(origPixels[i + 1],
                                              modifiedPixels[i + 1]);
            origPixels[i + 2] += modifiedPixels[i + 2] -
                                 2 * Math.min(origPixels[i + 2],
                                              modifiedPixels[i + 2]);
            origPixels[i + 3] = modifiedPixels[i + 3];
        }

        origContext.putImageData(origImageData, 0, 0);
    },

    /**
     * Handle the image scale being changed.
     *
     * Args:
     *     scale (number):
     *         The new image scaling factor (where 1.0 is 100%, 0.5 is 50%,
     *         etc).
     */
    _onScaleChanged(scale) {
        this._$canvas.css({
            width: this._maxWidth * scale + 'px',
            height: this._maxHeight * scale + 'px',
        });
    },

    /**
     * Return the initial size of the image.
     *
     * Returns:
     *     object:
     *     An object containing the initial height and width of the image.
     */
    getInitialSize() {
        return {
            width: this._maxWidth,
            height: this._maxHeight,
        };
    },
});


/**
 * Displays an onion skin view of two images.
 *
 * Onion skinning allows the user to see subtle changes in two images by
 * altering the transparency of the modified image. Through a slider, they'll
 * be able to play around with the transparency and see if any pixels move,
 * disappear, or otherwise change.
 */
const ImageOnionDiffView = BaseImageView.extend({
    mode: 'onion',
    name: gettext('Onion Skin'),

    template: _.template([
        '<div class="image-containers">',
        ' <div class="orig-image">',
        '  <img title="<%- caption %>" src="<%- diffAgainstImageURL %>" />',
        ' </div>',
        ' <div class="modified-image">',
        '  <img title="<%- caption %>" src="<%- imageURL %>" />',
        ' </div>',
        '</div>',
        '<div class="image-slider"></div>'
    ].join('')),

    DEFAULT_OPACITY: 0.25,

    /**
     * Initialize the view.
     */
    initialize() {
        _super(this).initialize.call(this);

        this._$origImage = null;
        this._$modifiedImage = null;
        this._$modifiedImageContainer = null;
    },

    /**
     * Render the view.
     *
     * This will set up the slider and set it to a default of 25% opacity.
     *
     * Returns:
     *     ImageOnionDiffView:
     *     This object, for chaining.
     */
    render() {
        this.$el.html(this.template(this.model.attributes));

        this.$commentRegion = this.$('.image-containers');
        this._$origImage = this.$('.orig-image img');
        this._$modifiedImage = this.$('.modified-image img');
        this._$modifiedImageContainer = this._$modifiedImage.parent();

        this.$('.image-slider')
            .slider({
                value: this.DEFAULT_OPACITY * 100,
                slide: (e, ui) => this.setOpacity(ui.value / 100.0)
            });

        this.setOpacity(this.DEFAULT_OPACITY);

        this.loadImages(this.$('img'));

        return this;
    },

    /**
     * Set the opacity value for the images.
     *
     * Args:
     *     percentage (number):
     *         The opacity percentage, in [0.0, 1.0].
     */
    setOpacity(percentage) {
        this._$modifiedImageContainer.css('opacity', percentage);
    },

    /**
     * Position the images after they load.
     *
     * The images will be layered on top of each other, consuming the
     * same width and height.
     */
    onImagesLoaded() {
        _super(this).onImagesLoaded.call(this);
        this._resize();
    },

    /**
     * Handle the image scale being changed.
     *
     * Args:
     *     scale (number):
     *         The new image scaling factor (where 1.0 is 100%, 0.5 is 50%,
     *         etc).
     */
    _onScaleChanged(scale) {
        _super(this)._onScaleChanged.call(this, scale);
        this._resize();
    },

    /**
     * Resize the image containers.
     */
    _resize() {
        const scale = this.model.get('scale');
        const origW = this._$origImage.data('initial-width') * scale;
        const origH = this._$origImage.data('initial-height') * scale;
        const newW = this._$modifiedImage.data('initial-width') * scale;
        const newH = this._$modifiedImage.data('initial-height') * scale;

        this._$origImage.parent()
            .width(origW)
            .height(origH);

        this._$modifiedImage.parent()
            .width(newW)
            .height(newH);

        this.$('.image-containers')
            .width(Math.max(origW, newW))
            .height(Math.max(origH, newH));
    },

    /**
     * Return the initial size of the image.
     *
     * Returns:
     *     object:
     *     An object containing the initial height and width of the image.
     */
    getInitialSize() {
        return {
            width: Math.max(this._$origImage.data('initial-width'),
                            this._$modifiedImage.data('initial-width')),
            height: Math.max(this._$origImage.data('initial-height'),
                             this._$modifiedImage.data('initial-height')),
        };
    },
});


/**
 * Displays an overlapping split view between two images.
 *
 * The images will be overlapping, and a horizontal slider will control how
 * much of the modified image is shown. The modified image will overlap the
 * original image. This makes it easy to compare the two images incrementally.
 */
const ImageSplitDiffView = BaseImageView.extend({
    mode: 'split',
    name: gettext('Split'),

    template: _.template([
        '<div class="image-containers">',
        ' <div class="image-diff-split-container-orig">',
        '  <div class="orig-image">',
        '   <img title="<%- caption %>" src="<%- diffAgainstImageURL %>" />',
        '  </div>',
        ' </div>',
        ' <div class="image-diff-split-container-modified">',
        '  <div class="modified-image">',
        '   <img title="<%- caption %>" src="<%- imageURL %>" />',
        '  </div>',
        ' </div>',
        '</div>',
        '<div class="image-slider"></div>'
    ].join('')),

    // The default slider position of 25%
    DEFAULT_SPLIT_PCT: 0.25,

    /**
     * Initialize the view.
     */
    initialize() {
        _super(this).initialize.call(this);

        this._$modifiedImage = null;
        this._$origImage = null;
        this._$origSplitContainer = null;
        this._$modifiedSplitContainer = null;
        this._$slider = null;
        this._maxWidth = 0;
    },

    /**
     * Render the view.
     *
     * Returns:
     *     ImageSplitDiffView:
     *     This object, for chaining.
     */
    render() {
        this.$el.html(this.template(this.model.attributes));

        this.$commentRegion = this.$('.image-containers');
        this._$origImage = this.$('.orig-image img');
        this._$modifiedImage = this.$('.modified-image img');
        this._$origSplitContainer = this.$('.image-diff-split-container-orig');
        this._$modifiedSplitContainer =
            this.$('.image-diff-split-container-modified');

        this._$slider = this.$('.image-slider')
            .slider({
                value: this.DEFAULT_SPLIT_PCT * 100,
                slide: (e, ui) => this.setSplitPercentage(ui.value / 100.0)
            });

        this.loadImages(this.$('img'));

        return this;
    },

    /**
     * Set the percentage for the split view.
     *
     * Args:
     *     percentage (number):
     *         The position of the slider, in [0.0, 1.0].
     */
    setSplitPercentage(percentage) {
        this._$origSplitContainer.outerWidth(this._maxWidth * percentage);
        this._$modifiedSplitContainer.outerWidth(
            this._maxWidth * (1.0 - percentage));
    },

    /**
     * Position the images after they load.
     *
     * The images will be layered on top of each other, anchored to the
     * top-left.
     *
     * The slider will match the width of the two images, in order to
     * position the slider's handle with the divider between images.
     */
    onImagesLoaded() {
        _super(this).onImagesLoaded.call(this);
        this._resize();
    },

    /**
     * Handle the image scale being changed.
     *
     * Args:
     *     scale (number):
     *         The new image scaling factor (where 1.0 is 100%, 0.5 is 50%,
     *         etc).
     */
    _onScaleChanged(scale) {
        _super(this)._onScaleChanged.call(this, scale);
        this._resize();
    },

    /**
     * Resize the image containers.
     */
    _resize() {
        const $origImageContainer = this._$origImage.parent();
        const scale = this.model.get('scale');
        const origW = this._$origImage.data('initial-width') * scale;
        const origH = this._$origImage.data('initial-height') * scale;
        const newW = this._$modifiedImage.data('initial-width') * scale;
        const newH = this._$modifiedImage.data('initial-height') * scale;
        const maxH = Math.max(origH, newH);
        const maxOuterH = maxH + $origImageContainer.getExtents('b', 'tb');

        this._maxWidth = Math.max(origW, newW);
        this._maxHeight = Math.max(origH, newH);

        $origImageContainer
            .outerWidth(origW)
            .height(origH);

        this._$modifiedImage.parent()
            .outerWidth(newW)
            .height(newH);

        this._$origSplitContainer
            .outerWidth(this._maxWidth)
            .height(maxOuterH);

        this._$modifiedSplitContainer
            .outerWidth(this._maxWidth)
            .height(maxOuterH);

        this.$('.image-containers')
            .width(this._maxWidth)
            .height(maxH);

        this._$slider.width(this._maxWidth);

        /* Now that these are loaded, set the default for the split. */
        this.setSplitPercentage(this.DEFAULT_SPLIT_PCT);
    },

    /**
     * Return the initial size of the image.
     *
     * Returns:
     *     object:
     *     An object containing the initial height and width of the image.
     */
    getInitialSize() {
        return {
            width: this._maxWidth,
            height: this._maxHeight,
        };
    },
});


/**
 * Displays a two-up, side-by-side view of two images.
 *
 * The images will be displayed horizontally, side-by-side. Comments will
 * only be able to be added against the new file.
 */
const ImageTwoUpDiffView = BaseImageView.extend({
    mode: 'two-up',
    name: gettext('Two-Up'),

    template: _.template([
        '<div class="image-container image-container-orig">',
        ' <div class="orig-image">',
        '  <img title="<%- caption %>" src="<%- diffAgainstImageURL %>" />',
        ' </div>',
        '</div>',
        '<div class="image-container image-container-modified">',
        ' <div class="modified-image">',
        '  <img title="<%- caption %>" src="<%- imageURL %>" />',
        ' </div>',
        '</div>'
    ].join('')),

    /**
     * Render the view.
     *
     * Returns:
     *     ImageTwoUpDiffView:
     *     This object, for chaining.
     */
    render() {
        this.$el.html(this.template(this.model.attributes));
        this.$commentRegion = this.$('.modified-image img');

        this._$origImage = this.$('.orig-image img');
        this._$modifiedImage = this.$('.modified-image img');

        this.loadImages(this.$('img'));

        return this;
    },

    /**
     * Return the initial size of the image.
     *
     * Returns:
     *     object:
     *     An object containing the initial height and width of the image.
     */
    getInitialSize() {
        return {
            width: Math.max(this._$origImage.data('initial-width'),
                            this._$modifiedImage.data('initial-width')),
            height: Math.max(this._$origImage.data('initial-height'),
                             this._$modifiedImage.data('initial-height')),
        };
    },
});


/**
 * Displays a review UI for images.
 *
 * This supports reviewing individual images, and diffs between images.
 *
 * In the case of individual images, the image will be displayed, centered,
 * and all existing comments will be shown as selection boxes on top of it.
 * Users can click and drag across part of the image to leave a comment on
 * that area.
 *
 * Image diffs can be shown in multiple modes. This supports a "two-up"
 * side-by-side view, an overlapping split view, Onion Skinning view, and
 * a color difference view.
 */
RB.ImageReviewableView = RB.FileAttachmentReviewableView.extend({
    className: 'image-review-ui',

    commentBlockView: RB.RegionCommentBlockView,

    events: {
        'click .image-diff-mode': '_onImageModeClicked',
        'click .image-resolution-menu .menu-item': '_onImageZoomLevelClicked',
        'mousedown .selection-container': '_onMouseDown',
        'mouseup .selection-container': '_onMouseUp',
        'mousemove .selection-container': '_onMouseMove'
    },

    modeItemTemplate: _.template(
        '<li><a class="image-diff-mode" href="#" data-mode="<%- mode %>"><%- name %></a></li>'
    ),

    captionTableTemplate: _.template(
        '<table><tr><%= items %></tr></table>'
    ),

    captionItemTemplate: _.template([
        '<td>',
        ' <h1 class="caption">',
        '  <%- caption %>',
        ' </h1>',
        '</td>'
    ].join('')),

    errorTemplate: _.template([
        '<div class="review-ui-error">',
        ' <div class="rb-icon rb-icon-warning"></div>',
        ' <%- errorStr %>',
        '</div>'
        ].join('')),

    ANIM_SPEED_MS: 200,

    /**
     * Initialize the view.
     */
    initialize() {
        RB.FileAttachmentReviewableView.prototype.initialize.apply(
            this, arguments);

        _.bindAll(this, '_adjustPos');

        this._activeSelection = {};
        this._diffModeSelectors = {};
        this._diffModeViews = {};
        this._commentBlockViews = [];

        /*
         * Add any CommentBlockViews to the selection area when they're
         * created.
         */
        this.on('commentBlockViewAdded', commentBlockView => {
            commentBlockView.setSelectionRegionSizeFunc(
                () => _.pick(this._imageView.getSelectionRegion(),
                             'width', 'height'));
            commentBlockView.setScale(this.model.get('scale'));

            this._$selectionArea.append(commentBlockView.$el);

            this._commentBlockViews.push(commentBlockView);
            this.listenTo(
                commentBlockView, 'removing', () => {
                    this._commentBlockViews =
                        _.without(this._commentBlockViews, commentBlockView);
                });
        });

        this.listenTo(this.model, 'change:scale', (model, scale) => {
            this._commentBlockViews.forEach(view => view.setScale(scale));

            this.$('.image-resolution-menu-current')
                .text(scalingFactors.get(scale));

            /*
             * We must wait for the image views to finish scaling themselves,
             * otherwise the comment blocks will be in incorrect places.
             */
            _.defer(this._adjustPos);
        });
    },

    /**
     * Render the view.
     *
     * This will set up a selection area over the image and create a
     * selection rectangle that will be shown when dragging.
     *
     * Any time the window resizes, the comment positions will be adjusted.
     *
     * Returns:
     *     RB.ImageReviewableView:
     *     This object, for chaining.
     */
    renderContent() {
        const hasDiff = !!this.model.get('diffAgainstFileAttachmentID');

        this._$selectionArea = $('<div/>')
            .addClass('selection-container')
            .hide()
            .proxyTouchEvents();

        this._$selectionRect = $('<div/>')
            .addClass('selection-rect')
            .prependTo(this._$selectionArea)
            .proxyTouchEvents()
            .hide();

        this.$el
            /*
             * Register a hover event to hide the comments when the mouse
             * is not over the comment area.
             */
            .hover(
                () => {
                    this._$selectionArea.show();
                    this._adjustPos();
                },
                () => {
                    if (this._$selectionRect.is(':hidden') &&
                        !this.commentDlg) {
                        this._$selectionArea.hide();
                    }
                });

        const $wrapper = $('<div class="image-content" />')
            .append(this._$selectionArea);

        if (this.model.get('diffTypeMismatch')) {
            this.$el.append(this.errorTemplate({
                errorStr: gettext('These revisions cannot be compared because they are different file types.')
            }));
        } else if (hasDiff) {
            this._$modeBar = $('<ul class="image-diff-modes"/>')
                .appendTo(this.$el);

            this._$imageDiffs = $('<div class="image-diffs"/>');

            this._addDiffMode(ImageTwoUpDiffView);
            this._addDiffMode(ImageDifferenceDiffView);
            this._addDiffMode(ImageSplitDiffView);
            this._addDiffMode(ImageOnionDiffView);

            $wrapper
                .append(this._$imageDiffs)
                .appendTo(this.$el);

            this._setDiffMode(ImageTwoUpDiffView.prototype.mode);
        } else {
            this._imageView = new ImageAttachmentView({
                model: this.model
            });

            $wrapper
                .append(this._imageView.$el)
                .appendTo(this.$el);

            this._imageView.render();
        }

        /*
         * Reposition the selection area on page resize or loaded, so that
         * comments are in the right locations.
         */
        $(window)
            .resize(this._adjustPos)
            .load(this._adjustPos);

        const $header = $('<div />')
            .addClass('review-ui-header')
            .prependTo(this.$el);

        if (this.model.get('numRevisions') > 1) {
            const $revisionLabel = $('<div id="revision_label" />')
                .appendTo($header);
            this._revisionLabelView = new RB.FileAttachmentRevisionLabelView({
                el: $revisionLabel,
                model: this.model
            });
            this._revisionLabelView.render();
            this.listenTo(this._revisionLabelView, 'revisionSelected',
                          this._onRevisionSelected);

            const $revisionSelector = $('<div id="attachment_revision_selector" />')
                .appendTo($header);
            this._revisionSelectorView = new RB.FileAttachmentRevisionSelectorView({
                el: $revisionSelector,
                model: this.model
            });
            this._revisionSelectorView.render();
            this.listenTo(this._revisionSelectorView, 'revisionSelected',
                          this._onRevisionSelected);

            if (!this.renderedInline) {
                if (hasDiff) {
                    const captionItems = [
                        this.captionItemTemplate({
                            caption: interpolate(
                                gettext('%(caption)s (revision %(revision)s)'),
                                {
                                    caption: this.model.get('diffCaption'),
                                    revision: this.model.get('diffRevision')
                                },
                                true)
                        }),
                        this.captionItemTemplate({
                            caption: interpolate(
                                gettext('%(caption)s (revision %(revision)s)'),
                                {
                                    caption: this.model.get('caption'),
                                    revision: this.model.get('fileRevision')
                                },
                                true)
                        })
                    ];

                    $header.append(this.captionTableTemplate({
                        items: captionItems.join('')
                    }));
                } else {
                    const $captionBar = $('<div class="image-single-revision">')
                        .appendTo($header);

                    $('<h1 class="caption" />')
                        .text(interpolate(
                            gettext('%(caption)s (revision %(revision)s)'),
                            {
                                caption: this.model.get('caption'),
                                revision: this.model.get('fileRevision')
                            },
                            true))
                        .appendTo($captionBar);
                }
            }
        } else {
            if (!this.renderedInline) {
                $header.addClass('image-single-revision');

                $('<h1 class="caption" />')
                    .text(this.model.get('caption'))
                    .appendTo($header);
            }
        }

        const $resolutionMenu = $([
          '<li class="image-resolution-menu has-menu">',
          ' <a href="#" class="menu-header">',
          '  <span class="fa fa-search-plus"></span>',
          '  <span class="image-resolution-menu-current">100%</span>',
          '  <span class="rb-icon rb-icon-dropdown-arrow">',
          ' </a>',
          ' <ul class="menu" />',
          '</li>',
        ].join(''));
        const $menu = $resolutionMenu.find('.menu');

        scalingFactors.forEach((text, scale) => {
            $(`<li class="menu-item" data-image-scale="${scale}" />`)
                .text(text)
                .appendTo($menu);
        });

        if (hasDiff) {
            this._$modeBar.append($resolutionMenu);
        } else {
            this.$('.caption').after($resolutionMenu);
        }
    },

    /**
     * Callback for when a new file revision is selected.
     *
     * This supports single revisions and diffs. If 'base' is 0, a
     * single revision is selected, If not, the diff between `base` and
     * `tip` will be shown.
     *
     * Args:
     *     revisions (Array of number):
     *         A two-element array of [base, tip] revisions.
     */
    _onRevisionSelected(revisions) {
        const revisionIDs = this.model.get('attachmentRevisionIDs');
        const [base, tip] = revisions;

        // Ignore clicks on No Diff Label
        if (tip === 0) {
            return;
        }

        /*
         * Eventually these hard redirects will use a router
         * (see diffViewerPageView.js for example)
         * this.router.navigate(base + '-' + tip + '/', {trigger: true});
         */
        const revisionTip = revisionIDs[tip-1];
        let redirectURL;

        if (base === 0) {
            redirectURL = `../${revisionTip}/`;
        } else {
            const revisionBase = revisionIDs[base-1];
            redirectURL = `../${revisionBase}-${revisionTip}/`;
        }

        window.location.replace(redirectURL);
    },

    /**
     * Register a diff mode.
     *
     * This will register a class for the mode and add an entry to the
     * mode bar.
     *
     * Args:
     *     ViewClass (function):
     *         The constructor for the view class.
     */
    _addDiffMode(ViewClass) {
        const mode = ViewClass.prototype.mode;
        const view = new ViewClass({
            model: this.model
        });

        this._diffModeViews[mode] = view;
        view.$el.hide();
        this._$imageDiffs.append(view.$el);
        view.render();

        const $selector = $(this.modeItemTemplate({
            mode: mode,
            name: view.name
        }));

        /*
         * Since we're making the text bold when selected, we need to reserve
         * the right amount of space for the bold text, so that the contents
         * don't shift.
         *
         * This is kind of ugly, but really the only good way.
         */
        $selector
            .appendTo(this._$modeBar)
            .addClass('selected');
        const selectorWidth = $selector.outerWidth(true);
        $selector
            .removeClass('selected')
            .width(selectorWidth);

        this._diffModeSelectors[mode] = $selector;
    },

    /**
     * Set the current diff mode.
     *
     * That mode will be displayed on the page and comments will be shown.
     *
     * The height of the review UI will animate to the new height for this
     * mode.
     *
     * Args:
     *     mode (string):
     *         The new diff mode.
     */
    _setDiffMode(mode) {
        const newView = this._diffModeViews[mode];

        if (this._imageView) {
            this._diffModeSelectors[this._imageView.mode]
                .removeClass('selected');

            newView.$el.show();
            const height = newView.$el.height();
            newView.$el.hide();

            this._$imageDiffs.animate({
                height: height,
                duration: this.ANIM_SPEED_MS
            });

            this._$selectionArea.fadeOut(this.ANIM_SPEED_MS);
            this._imageView.$el.fadeOut(
                this.ANIM_SPEED_MS,
                () => this._showDiffMode(newView, true));
        } else {
            this._showDiffMode(newView);
        }

        this._diffModeSelectors[newView.mode]
            .addClass('selected');
    },

    /**
     * Show the diff mode.
     *
     * This is called by _setDiffMode when it's ready to actually show the
     * new mode.
     *
     * The new mode will be faded in, if we're animating, or immediately shown
     * otherwise.
     *
     * Args:
     *     newView (Backbone.View):
     *         The new view to show.
     *
     *     animate (boolean):
     *         Whether to animate the transition.
     */
    _showDiffMode(newView, animate) {
        if (this._imageView) {
            this.stopListening(this._imageView, 'regionChanged');
        }

        this._imageView = newView;

        if (animate) {
            this._imageView.$el.fadeIn(this.ANIM_SPEED_MS);
            this._$selectionArea.fadeIn(this.ANIM_SPEED_MS);
        } else {
            this._imageView.$el.show();
            this._$selectionArea.show();
        }

        this.listenTo(this._imageView, 'regionChanged', this._adjustPos);

        this._adjustPos();
    },

    /**
     * Handler for when a mode in the diff mode bar is clicked.
     *
     * Sets the diff view to the given mode.
     *
     * Args:
     *     e (Event):
     *         The event which triggered the callback.
     */
    _onImageModeClicked(e) {
        e.preventDefault();
        e.stopPropagation();

        this._setDiffMode($(e.target).data('mode'));
    },

    /**
     * Handler for when a zoom level is clicked.
     *
     * Args:
     *     e (Event):
     *         The event which triggered the callback.
     */
    _onImageZoomLevelClicked(e) {
        e.preventDefault();
        e.stopPropagation();
        this.model.set('scale', $(e.target).data('image-scale'));
    },

    /**
     * Handle a mousedown on the selection area.
     *
     * If this is the first mouse button, and it's not being placed on
     * an existing comment block, then this will begin the creation of a new
     * comment block starting at the mousedown coordinates.
     *
     * Args:
     *     e (Event):
     *         The event which triggered the callback.
     */
    _onMouseDown(e) {
        if (e.which === 1 &&
            !this.commentDlg &&
            !$(e.target).hasClass('selection-flag')) {
            const offset = this._$selectionArea.offset();

            this._activeSelection.beginX =
                e.pageX - Math.floor(offset.left) - 1;
            this._activeSelection.beginY =
                e.pageY - Math.floor(offset.top) - 1;

            const updateData = {
                left: this._activeSelection.beginX,
                top: this._activeSelection.beginY,
                width: 1,
                height: 1
            };

            this._$selectionRect
                .css(updateData)
                .data(updateData)
                .show();

            if (this._$selectionRect.is(':hidden')) {
                this.commentDlg.close();
            }

            return false;
        }
    },

    /**
     * Handle a mouseup on the selection area.
     *
     * This will finalize the creation of a comment block and pop up the
     * comment dialog.
     *
     * Args:
     *     e (Event):
     *         The event which triggered the callback.
     */
    _onMouseUp(e) {
        if (!this.commentDlg &&
            this._$selectionRect.is(':visible')) {
            e.stopPropagation();
            this._$selectionRect.hide();

            /*
             * If we don't pass an arbitrary minimum size threshold,
             * don't do anything. This helps avoid making people mad
             * if they accidentally click on the image.
             */
            const position = this._$selectionRect.data();
            const scale = this.model.get('scale');

            if (position.width > 5 && position.height > 5) {
                this.createAndEditCommentBlock({
                    x: Math.floor(position.left / scale),
                    y: Math.floor(position.top / scale),
                    width: Math.floor(position.width / scale),
                    height: Math.floor(position.height / scale),
                });
            }
        }
    },

    /**
     * Handle a mousemove on the selection area.
     *
     * If we're creating a comment block, this will update the
     * size/position of the block.
     *
     * Args:
     *     e (Event):
     *         The event which triggered the callback.
     */
    _onMouseMove(e) {
        if (!this.commentDlg && this._$selectionRect.is(':visible')) {
            const offset = this._$selectionArea.offset();
            const x = e.pageX - Math.floor(offset.left) - 1;
            const y = e.pageY - Math.floor(offset.top) - 1;
            const updateData = {};

            if (this._activeSelection.beginX <= x) {
                updateData.left = this._activeSelection.beginX;
                updateData.width = x - this._activeSelection.beginX;
            } else {
                updateData.left = x;
                updateData.width = this._activeSelection.beginX - x;
            }

            if (this._activeSelection.beginY <= y) {
                updateData.top = this._activeSelection.beginY;
                updateData.height = y - this._activeSelection.beginY;
            } else {
                updateData.top = y;
                updateData.height = this._activeSelection.beginY - y;
            }

            this._$selectionRect
                .css(updateData)
                .data(updateData);

            return false;
        }
    },

    /**
     * Reposition the selection area to the right locations.
     */
    _adjustPos() {
        const region = this._imageView.getSelectionRegion();

        this._$selectionArea
            .width(region.width)
            .height(region.height)
            .css({
                left: region.left,
                top: region.top
            });

        if (this._$imageDiffs) {
            this._$imageDiffs.height(this._imageView.$el.height());
        }
    }
});


})();
