const DiffPage = require( './ext.RevisionSlider.DiffPage.js' ),
	HelpButtonView = require( './ext.RevisionSlider.HelpButtonView.js' ),
	makeRevisions = require( './ext.RevisionSlider.RevisionList.js' ).makeRevisions,
	Pointer = require( './ext.RevisionSlider.Pointer.js' ),
	RevisionListView = require( './ext.RevisionSlider.RevisionListView.js' ),
	RevisionSliderApi = require( './ext.RevisionSlider.Api.js' ),
	SliderArrowView = require( './ext.RevisionSlider.SliderArrowView.js' ),
	utils = require( './ext.RevisionSlider.util.js' );

/**
 * Module handling the view logic of the RevisionSlider slider
 *
 * @class SliderView
 * @param {Slider} slider
 * @param {DiffPage} [diffPage] Defaults to creating a new DiffPage without parameters
 * @constructor
 */
function SliderView( slider, diffPage ) {
	this.slider = slider;
	this.diffPage = diffPage || new DiffPage();
	this.diffPage.addHandlersToCoreLinks( this );
	this.diffPage.initOnPopState( this );
}

Object.assign( SliderView.prototype, {

	revisionWidth: 16,
	containerMargin: 140,
	outerMargin: 20,

	/** @type {jQuery} */
	$element: null,
	/** @type {DiffPage} */
	diffPage: null,
	/** @type {Slider} */
	slider: null,
	/** @type {Pointer} */
	pointerOlder: null,
	/** @type {Pointer} */
	pointerNewer: null,
	/** @type {OO.ui.ButtonWidget} */
	backwardArrowButton: null,
	/** @type {OO.ui.ButtonWidget} */
	forwardArrowButton: null,
	/** @type {boolean} */
	noMoreNewerRevisions: false,
	/** @type {boolean} */
	noMoreOlderRevisions: false,
	/** @type {string|null} */
	dir: null,
	/** @type {boolean} */
	isDragged: false,
	/** @type {boolean} */
	escapePressed: false,
	/** @type {number|null} */
	lastOldPointerPosition: null,
	/** @type {number|null} */
	lastNewPointerPosition: null,

	render: function ( $container ) {
		const containerWidth = this.calculateSliderContainerWidth(),
			$revisions = this.getRevisionListView().render( this.revisionWidth ),
			sliderArrowView = new SliderArrowView( this );

		this.dir = $container.css( 'direction' );

		this.pointerOlder = this.pointerOlder || new Pointer( 'mw-revslider-pointer-older' );
		this.pointerNewer = this.pointerNewer || new Pointer( 'mw-revslider-pointer-newer' );

		this.backwardArrowButton = sliderArrowView.renderArrowButton( -1 );
		this.forwardArrowButton = sliderArrowView.renderArrowButton( 1 );

		this.$element = $( '<div>' )
			.addClass( 'mw-revslider-revision-slider' )
			.css( {
				direction: $container.css( 'direction' ),
				width: ( containerWidth + this.containerMargin ) + 'px'
			} )
			.append(
				this.backwardArrowButton.$element,
				this.renderRevisionsContainer( containerWidth, $revisions ),
				this.renderPointerContainer( containerWidth ),
				this.forwardArrowButton.$element,
				$( '<div>' ).css( { clear: 'both' } ),
				this.pointerOlder.getLine().getElement(), this.pointerNewer.getLine().getElement(),
				HelpButtonView.render()
			);

		this.initPointers( $revisions );

		this.slider.setRevisionsPerWindow( this.$element.find( '.mw-revslider-revisions-container' ).width() / this.revisionWidth );

		this.initializePointers( this.getOldRevElement( $revisions ), this.getNewRevElement( $revisions ) );
		this.resetRevisionStylesBasedOnPointerPosition( $revisions );
		this.addClickHandlerToRevisions( $revisions );

		$container.empty().append( this.$element );

		this.slideView( Math.floor( ( this.getNewerPointerPos() - 1 ) / this.slider.getRevisionsPerWindow() ), 0 );
		this.diffPage.replaceState( mw.config.get( 'wgDiffNewId' ), mw.config.get( 'wgDiffOldId' ), this );
	},

	/**
	 * Renders the revisions container and adds the revisions to it
	 *
	 * @private
	 * @param {number} containerWidth
	 * @param {jQuery} $revisions
	 * @return {jQuery} the revisions container
	 */
	renderRevisionsContainer: function ( containerWidth, $revisions ) {
		return $( '<div>' )
			.addClass( 'mw-revslider-revisions-container' )
			.css( {
				width: containerWidth + 'px'
			} )
			.append( $revisions );
	},

	/**
	 * Renders the pointer container and adds the pointers to it
	 *
	 * @private
	 * @param {number} containerWidth
	 * @return {jQuery} the pointer container
	 */
	renderPointerContainer: function ( containerWidth ) {
		const pointerContainerWidth = containerWidth + this.revisionWidth - 1;
		let pointerContainerPosition = 53,
			pointerContainerStyle, lastMouseMoveRevisionPos;

		pointerContainerStyle = { left: pointerContainerPosition + 'px', width: pointerContainerWidth + 'px' };
		if ( this.dir === 'rtl' ) {
			// Due to properly limit dragging a pointer on the right side of the screen,
			// there must some extra space added to the right of the revision bar container
			// For this reason right position of the pointer container in the RTL mode is
			// a bit moved off right compared to its left position in the LTR mode
			pointerContainerPosition = pointerContainerPosition - this.revisionWidth + 1;
			pointerContainerStyle = { right: pointerContainerPosition + 'px', width: pointerContainerWidth + 'px' };
		}

		return $( '<div>' )
			.addClass( 'mw-revslider-pointer-container' )
			.css( pointerContainerStyle )
			.append( this.renderPointerContainers() )
			.on( 'click', this.revisionsClickHandler.bind( this ) )
			.on( 'mouseout', () => {
				if ( !this.isDragged ) {
					this.getRevisionListView().removeAllRevisionPreviewHighlights();
				}
			} )
			.on( 'mouseover', ( event ) => {
				if ( !this.isDragged ) {
					lastMouseMoveRevisionPos = this.pointerContainerMouseMoveHandler(
						event,
						null
					);
				}
			} )
			.on( 'mousemove', ( event ) => {
				if ( !this.isDragged ) {
					lastMouseMoveRevisionPos = this.pointerContainerMouseMoveHandler(
						event,
						lastMouseMoveRevisionPos
					);
				}
			} );
	},

	/**
	 * @private
	 * @return {jQuery[]}
	 */
	renderPointerContainers: function () {
		return [
			$( '<div>' )
				.addClass( 'mw-revslider-pointer-container-older' )
				.append(
					$( '<div>' ).addClass( 'mw-revslider-slider-line' ),
					this.pointerOlder.getView().getElement()
				),
			$( '<div>' )
				.addClass( 'mw-revslider-pointer-container-newer' )
				.append(
					$( '<div>' ).addClass( 'mw-revslider-slider-line' ),
					this.pointerNewer.getView().getElement()
				)
		];
	},

	/**
	 * Initializes the pointer dragging logic
	 *
	 * @private
	 * @param {jQuery} $revisions
	 */
	initPointers: function ( $revisions ) {
		const $pointers = this.$element.find( '.mw-revslider-pointer' ),
			$pointerOlder = this.pointerOlder.getView().getElement(),
			$pointerNewer = this.pointerNewer.getView().getElement(),
			self = this;

		$pointerNewer.attr( 'tabindex', 0 );
		$pointerOlder.attr( 'tabindex', 0 );

		$( document.body ).on( 'keydown', ( e ) => {
			if ( e.which === OO.ui.Keys.ESCAPE ) {
				this.escapePressed = true;
				$pointers.trigger( 'mouseup' );
			}
		} );

		$pointers
			.on( 'focus', ( event ) => {
				this.onPointerFocus( event, $revisions );
			} )
			.on( 'blur', this.getRevisionListView().onFocusBlur.bind( this.getRevisionListView() ) )
			.on( 'keydown', function ( event ) {
				self.buildTabbingRulesOnKeyDown( $( this ), event, $revisions );
			} )
			.on( 'keyup', function ( event ) {
				self.buildTabbingRulesOnKeyUp( $( this ), event, $revisions );
			} )
			.on(
				'touchstart touchmove touchend touchcancel touchleave',
				utils.touchEventConverter
			);

		$pointerOlder.draggable( this.buildDraggableOptions(
			$revisions,
			'.mw-revslider-pointer-container-older'
		) );

		$pointerNewer.draggable( this.buildDraggableOptions(
			$revisions,
			'.mw-revslider-pointer-container-newer'
		) );
	},

	/**
	 * @private
	 * @return {number}
	 */
	getOlderPointerPos: function () {
		return this.pointerOlder.getPosition();
	},

	/**
	 * @private
	 * @return {number}
	 */
	getNewerPointerPos: function () {
		return this.pointerNewer.getPosition();
	},

	/**
	 * @private
	 * @param {number} pos
	 * @return {number}
	 */
	setOlderPointerPos: function ( pos ) {
		return this.pointerOlder.setPosition( pos );
	},

	/**
	 * @private
	 * @param {number} pos
	 * @return {number}
	 */
	setNewerPointerPos: function ( pos ) {
		return this.pointerNewer.setPosition( pos );
	},

	/**
	 * @private
	 * @param {MouseEvent} event
	 * @param {number} lastValidPosition
	 * @return {number}
	 */
	pointerContainerMouseMoveHandler: function ( event, lastValidPosition ) {
		const pos = this.getRevisionPositionFromLeftOffset( event.pageX );

		if ( pos === lastValidPosition ) {
			return pos;
		}

		const $hoveredRevisionWrapper = this.getRevElementAtPosition( this.getRevisionsElement(), pos ).parent();
		this.getRevisionListView().removeAllRevisionPreviewHighlights();
		if ( $hoveredRevisionWrapper.length ) {
			this.getRevisionListView().onRevisionHover( $hoveredRevisionWrapper, event );
		}

		return pos;
	},

	/**
	 * React on clicks on a revision element and move pointers
	 *
	 * @private
	 * @param {MouseEvent} event
	 */
	revisionsClickHandler: function ( event ) {
		let clickedPos;
		// Just use the information from the element that received the click, if available
		const $revElement = $( event.currentTarget ).find( '.mw-revslider-revision' );
		if ( $revElement.length === 1 ) {
			clickedPos = +$revElement.attr( 'data-pos' );
		} else {
			clickedPos = this.getRevisionPositionFromLeftOffset( event.pageX );
		}
		const $revisionWrapper = this.getRevElementAtPosition( this.getRevisionsElement(), clickedPos ).parent();
		// Fail-safe in case a mouse click outside the valid range is picked up
		if ( !$revisionWrapper.length ) {
			return;
		}
		const hasClickedTop = event.pageY - $revisionWrapper.offset().top < $revisionWrapper.height() / 2;
		let newNewerPointerPos, newOlderPointerPos;

		if ( hasClickedTop &&
			( $revisionWrapper.hasClass( 'mw-revslider-revision-newer' ) ||
				$revisionWrapper.hasClass( 'mw-revslider-revision-intermediate' ) )
		) {
			newNewerPointerPos = clickedPos;
			newOlderPointerPos = this.pointerOlder.getPosition();
		} else if ( !hasClickedTop &&
			( $revisionWrapper.hasClass( 'mw-revslider-revision-older' ) ||
				$revisionWrapper.hasClass( 'mw-revslider-revision-intermediate' ) )
		) {
			newNewerPointerPos = this.pointerNewer.getPosition();
			newOlderPointerPos = clickedPos;
		} else {
			newNewerPointerPos = clickedPos;
			newOlderPointerPos = clickedPos;
			if ( hasClickedTop ) {
				newOlderPointerPos--;
			} else {
				newNewerPointerPos++;
			}
		}

		if (
			newOlderPointerPos === newNewerPointerPos ||
			!this.slider.getRevisionList().isValidPosition( newOlderPointerPos ) ||
			!this.slider.getRevisionList().isValidPosition( newNewerPointerPos )
		) {
			return;
		}

		this.updatePointersAndDiffView( newNewerPointerPos, newOlderPointerPos );
	},

	/**
	 * @private
	 * @param {jQuery} $revisions
	 */
	addClickHandlerToRevisions: function ( $revisions ) {
		$revisions.find( '.mw-revslider-revision-wrapper' )
			.on( 'click', this.revisionsClickHandler.bind( this ) );
	},

	/**
	 * @private
	 * @param {MouseEvent} event
	 * @param {jQuery} $revisions
	 */
	onPointerFocus: function ( event, $revisions ) {
		const $hoveredRevisionWrapper = this.getRevElementAtPosition(
			$revisions,
			this.whichPointer( $( event.target ) ).getPosition()
		).parent();
		this.getRevisionListView().setRevisionFocus( $hoveredRevisionWrapper );

	},

	/**
	 * Build rules for tabbing when `keydown` event triggers on pointers
	 *
	 * @private
	 * @param {jQuery} $pointer
	 * @param {KeyboardEvent} event
	 * @param {jQuery} $revisions
	 */
	buildTabbingRulesOnKeyDown: function ( $pointer, event, $revisions ) {
		const oldPos = this.getOlderPointerPos(),
			newPos = this.getNewerPointerPos(),
			pointer = this.whichPointer( $pointer ),
			isNewer = pointer.getView().isNewerPointer();
		let offset = 0;

		if ( event.which === ( this.dir === 'rtl' ? OO.ui.Keys.LEFT : OO.ui.Keys.RIGHT ) ) {
			offset = 1;

			if ( isNewer ) {
				if ( newPos === this.slider.getNewestVisibleRevisionIndex() + 1 ) {
					return;
				}
				this.setNewerPointerPos( newPos + 1 );
			} else {
				if ( oldPos !== newPos - 1 ) {
					this.setOlderPointerPos( oldPos + 1 );
				}
			}
		} else if ( event.which === ( this.dir === 'rtl' ? OO.ui.Keys.RIGHT : OO.ui.Keys.LEFT ) ) {
			offset = -1;

			if ( isNewer ) {
				if ( oldPos !== newPos - 1 ) {
					this.setNewerPointerPos( newPos - 1 );
				}
			} else {
				if ( oldPos === this.slider.getOldestVisibleRevisionIndex() + 1 ) {
					return;
				}
				this.setOlderPointerPos( oldPos - 1 );
			}
		} else if ( event.which !== OO.ui.Keys.ENTER ) {
			return;
		}

		this.resetRevisionStylesBasedOnPointerPosition( $revisions );
		this.alignPointersAndLines( 1 );

		const pos = this.getRevisionPositionFromLeftOffset(
			$pointer.offset().left + this.revisionWidth / 2
		) + offset;

		const $hoveredRevisionWrapper = this.getRevElementAtPosition( $revisions, pos ).parent();

		if ( $( '.mw-revslider-revision-tooltip' ).length && event.which === OO.ui.Keys.ENTER ) {
			this.getRevisionListView().removeCurrentRevisionFocus();
		} else {
			this.getRevisionListView().setRevisionFocus( $hoveredRevisionWrapper );
		}

	},

	/**
	 * Build rules for tabbing when `keyup` event triggers on pointers
	 *
	 * @private
	 * @param {jQuery} $pointer
	 * @param {KeyboardEvent} event
	 * @param {jQuery} $revisions
	 */
	buildTabbingRulesOnKeyUp: function ( $pointer, event, $revisions ) {
		if ( event.which !== OO.ui.Keys.RIGHT && event.which !== OO.ui.Keys.LEFT ) {
			return;
		}

		const diff = +this.getRevElementAtPosition(
			$revisions, this.getNewerPointerPos()
		).attr( 'data-revid' );

		const oldid = +this.getRevElementAtPosition(
			$revisions, this.getOlderPointerPos()
		).attr( 'data-revid' );

		this.lastRequest = this.refreshDiffView( diff, oldid );

		this.lastRequest.then( () => {
			$pointer.trigger( 'focus' );
		} );
	},

	/**
	 * Build options for the draggable
	 *
	 * @private
	 * @param {jQuery} $revisions
	 * @param {string} containmentClass
	 * @return {Object}
	 */
	buildDraggableOptions: function ( $revisions, containmentClass ) {
		let lastValidLeftPos;
		const self = this;

		return {
			axis: 'x',
			grid: [ this.revisionWidth, null ],
			containment: containmentClass,
			start: function () {
				if ( self.pointerIsBlockedByOther( this ) ) {
					return false;
				}
				self.isDragged = true;
				self.getRevisionListView().enableRevisionPreviewHighlights( false );
				self.setPointerDragCursor();
				self.fadeOutPointerLines( true );
				self.escapePressed = false;
				self.lastOldPointerPosition = self.getOlderPointerPos();
				self.lastNewPointerPosition = self.getNewerPointerPos();
			},
			stop: function () {
				const $p = $( this ),
					relativeIndex = self.getRelativePointerIndex( $p ),
					pointer = self.whichPointer( $p );

				self.isDragged = false;
				self.getRevisionListView().enableRevisionPreviewHighlights();
				self.setPointerDragCursor( false );

				if ( self.escapePressed ) {
					return;
				}

				utils.incrementEventStats( 'pointerMove' );

				pointer.setPosition( self.slider.getOldestVisibleRevisionIndex() + relativeIndex );

				const diff = +self.getRevElementAtPosition(
					$revisions, self.getNewerPointerPos()
				).attr( 'data-revid' );

				const oldid = +self.getRevElementAtPosition(
					$revisions, self.getOlderPointerPos()
				).attr( 'data-revid' );

				if ( self.getNewerPointerPos() === self.lastNewPointerPosition &&
					self.getOlderPointerPos() === self.lastOldPointerPosition ) {
					return;
				}

				self.refreshDiffView( diff, oldid );
				self.alignPointersAndLines( 0 );
				self.resetRevisionStylesBasedOnPointerPosition( $revisions );
			},
			drag: function ( event, ui ) {
				lastValidLeftPos = self.draggableDragAction(
					event,
					ui,
					this,
					lastValidLeftPos
				);
			},
			revert: function () {
				return self.escapePressed;
			}
		};
	},

	/**
	 * @private
	 * @param {Element} pointerElement
	 * @return {boolean}
	 */
	pointerIsBlockedByOther: function ( pointerElement ) {
		const pointer = this.whichPointer( $( pointerElement ) ),
			isNewer = pointer.getView().isNewerPointer();

		return ( isNewer && this.getOlderPointerPos() >= this.slider.getNewestVisibleRevisionIndex() + 1 ) ||
			( !isNewer && this.getNewerPointerPos() <= this.slider.getOldestVisibleRevisionIndex() + 1 );
	},

	/**
	 * @private
	 * @param {Event} event
	 * @param {Object} ui
	 * @param {Element} pointer
	 * @param {number} lastValidLeftPos
	 * @return {number}
	 */
	draggableDragAction: function ( event, ui, pointer, lastValidLeftPos ) {
		const pos = this.getRevisionPositionFromLeftOffset(
			$( pointer ).offset().left + this.revisionWidth / 2
		);

		if ( pos === lastValidLeftPos ) {
			return pos;
		}

		const $revisions = this.getRevisionsElement();
		const $hoveredRevisionWrapper = this.getRevElementAtPosition( $revisions, pos ).parent();
		this.getRevisionListView().setRevisionFocus( $hoveredRevisionWrapper );

		return pos;
	},

	/**
	 * @private
	 * @param {number} leftOffset
	 * @return {number}
	 */
	getRevisionPositionFromLeftOffset: function ( leftOffset ) {
		const $revisions = this.getRevisionsElement(),
			revisionsX = utils.correctElementOffsets( $revisions.offset() ).left;
		let pos = Math.ceil( Math.abs( leftOffset - revisionsX ) / this.revisionWidth );

		if ( this.dir === 'rtl' ) {
			// pre-loading the revisions on the right side leads to shifted position numbers
			if ( this.slider.isAtStart() ) {
				pos = this.slider.getRevisionsPerWindow() - pos + 1;
			} else {
				pos += this.slider.getRevisionsPerWindow();
			}
		}

		return pos;
	},

	/**
	 * @private
	 * @param {boolean} [show=true]
	 */
	setPointerDragCursor: function ( show ) {
		$( '.mw-revslider-pointer, ' +
			'.mw-revslider-pointer-container, ' +
			'.mw-revslider-pointer-container-newer, ' +
			'.mw-revslider-pointer-container-older, ' +
			'.mw-revslider-pointer-line, ' +
			'.mw-revslider-revision-wrapper' )
			.toggleClass( 'mw-revslider-pointer-grabbing', show !== false );
	},

	/**
	 * Get the relative index for a pointer.
	 *
	 * @private
	 * @param {jQuery} $pointer
	 * @return {number}
	 */
	getRelativePointerIndex: function ( $pointer ) {
		let pos = $pointer.position().left;
		const pointer = this.whichPointer( $pointer );

		if ( this.dir === 'rtl' ) {
			pos = pointer.getView().getAdjustedLeftPositionWhenRtl( pos );
		}
		return Math.ceil( ( pos + this.revisionWidth / 2 ) / this.revisionWidth );
	},

	/**
	 * Loads a new diff and optionally adds a state to the history
	 *
	 * @private
	 * @param {number} diff
	 * @param {number} oldid
	 * @param {boolean} [pushState=true] False to skip manipulating the browser history
	 * @return {jQuery}
	 */
	refreshDiffView: function ( diff, oldid, pushState ) {
		this.diffPage.refresh( diff, oldid, this );
		if ( pushState !== false ) {
			this.diffPage.pushState( diff, oldid, this );
		}
		return this.diffPage.lastRequest;
	},

	showNextDiff: function () {
		this.updatePointersAndDiffView(
			this.getNewerPointerPos() + 1,
			this.getNewerPointerPos()
		);
	},

	showPrevDiff: function () {
		this.updatePointersAndDiffView(
			this.getOlderPointerPos(),
			this.getOlderPointerPos() - 1
		);
	},

	/**
	 * Updates and moves pointers to new positions, resets styles and refreshes diff accordingly
	 *
	 * @param {number} newPointerPos
	 * @param {number} oldPointerPos
	 * @param {boolean} [pushState=true] False to skip manipulating the browser history
	 */
	updatePointersAndDiffView: function (
		newPointerPos,
		oldPointerPos,
		pushState
	) {
		this.setNewerPointerPos( newPointerPos );
		this.setOlderPointerPos( oldPointerPos );
		this.alignPointersAndLines();
		this.resetRevisionStylesBasedOnPointerPosition( this.getRevisionsElement() );
		this.refreshDiffView(
			+$( '.mw-revslider-revision[data-pos="' + newPointerPos + '"]' ).attr( 'data-revid' ),
			+$( '.mw-revslider-revision[data-pos="' + oldPointerPos + '"]' ).attr( 'data-revid' ),
			pushState
		);
	},

	/**
	 * @private
	 * @param {jQuery} $revs
	 * @param {number} pos
	 * @return {jQuery}
	 */
	getRevElementAtPosition: function ( $revs, pos ) {
		return $revs.find( '.mw-revslider-revision[data-pos="' + pos + '"]' );
	},

	/**
	 * Gets the jQuery element of the older selected revision
	 *
	 * @private
	 * @param {jQuery} $revs
	 * @return {jQuery}
	 */
	getOldRevElement: function ( $revs ) {
		return $revs.find( '.mw-revslider-revision[data-revid="' + mw.config.get( 'wgDiffOldId' ) + '"]' );
	},

	/**
	 * Gets the jQuery element of the newer selected revision
	 *
	 * @private
	 * @param {jQuery} $revs
	 * @return {jQuery}
	 */
	getNewRevElement: function ( $revs ) {
		return $revs.find( '.mw-revslider-revision[data-revid="' + mw.config.get( 'wgDiffNewId' ) + '"]' );
	},

	/**
	 * Initializes the Pointer objects based on the selected revisions
	 *
	 * @private
	 * @param {jQuery} $oldRevElement
	 * @param {jQuery} $newRevElement
	 */
	initializePointers: function ( $oldRevElement, $newRevElement ) {
		if ( this.getOlderPointerPos() !== 0 || this.getNewerPointerPos() !== 0 ) {
			return;
		}
		if ( !$oldRevElement.length && !$newRevElement.length ) {
			// Note: this is currently caught in init.js
			throw new Error( 'RS-revs-not-specified' );
		}
		this.setOlderPointerPos( $oldRevElement.length ? +$oldRevElement.attr( 'data-pos' ) : -1 );
		this.setNewerPointerPos( +$newRevElement.attr( 'data-pos' ) );
		this.resetSliderLines();
	},

	/**
	 * Resets the slider lines based on the selected revisions
	 *
	 * @private
	 */
	resetSliderLines: function () {
		this.setSliderLineCSS(
			$( '.mw-revslider-pointer-container-older' ),
			this.getOlderDistanceToOldest() + this.getDistanceBetweenPointers(),
			0
		);
		this.setSliderLineCSS(
			$( '.mw-revslider-pointer-container-newer' ),
			this.getNewerDistanceToNewest() + this.getDistanceBetweenPointers() + 2,
			this.getOlderDistanceToOldest()
		);
	},

	/**
	 * @private
	 * @param {jQuery} $lineContainer
	 * @param {number} widthToSet Total width of the line, in position units
	 * @param {number} marginToSet Start of the line from the left, in position units
	 */
	setSliderLineCSS: function ( $lineContainer, widthToSet, marginToSet ) {
		const maxWidth = this.calculateSliderContainerWidth() + this.revisionWidth;
		widthToSet = Math.min( widthToSet * this.revisionWidth, maxWidth );
		marginToSet = Math.max( 0, marginToSet * this.revisionWidth ) - this.revisionWidth / 2;

		$lineContainer.css( 'width', widthToSet );
		if ( this.dir === 'ltr' ) {
			$lineContainer.css( 'margin-left', marginToSet );
		} else {
			$lineContainer.css( 'margin-right', marginToSet + this.revisionWidth );
		}
	},

	/**
	 * @private
	 * @return {number}
	 */
	getOlderDistanceToOldest: function () {
		return this.getOlderPointerPos() - this.slider.getOldestVisibleRevisionIndex();
	},

	/**
	 * @private
	 * @return {number}
	 */
	getNewerDistanceToNewest: function () {
		return this.slider.getNewestVisibleRevisionIndex() - this.getNewerPointerPos();
	},

	/**
	 * @private
	 * @return {number} difference between the two positions, typically 1
	 */
	getDistanceBetweenPointers: function () {
		return this.getNewerPointerPos() - this.getOlderPointerPos();
	},

	/**
	 * Highlights revisions between the pointers
	 *
	 * @private
	 * @param {jQuery} $revisions
	 */
	resetRevisionStylesBasedOnPointerPosition: function ( $revisions ) {
		const olderRevPosition = this.getOlderPointerPos(),
			newerRevPosition = this.getNewerPointerPos(),
			// FIXME: Why do these methods return values that are off by one?
			startPosition = this.slider.getOldestVisibleRevisionIndex() + 1,
			endPosition = this.slider.getNewestVisibleRevisionIndex() + 1;

		// We need to reset these in case they are outside the visible range
		$revisions.find( '.mw-revslider-revision' )
			.removeClass( 'mw-revslider-revision-old mw-revslider-revision-new' );

		this.getRevElementAtPosition( $revisions, olderRevPosition ).addClass( 'mw-revslider-revision-old' );
		this.getRevElementAtPosition( $revisions, newerRevPosition ).addClass( 'mw-revslider-revision-new' );

		for ( let i = startPosition; i <= endPosition; i++ ) {
			const older = i <= olderRevPosition;
			const newer = i >= newerRevPosition;
			this.getRevElementAtPosition( $revisions, i ).parent()
				.toggleClass( 'mw-revslider-revision-older', older )
				.toggleClass( 'mw-revslider-revision-intermediate', !older && !newer )
				.toggleClass( 'mw-revslider-revision-newer', newer );
		}
	},

	/**
	 * Redraws the lines for the pointers
	 *
	 * @private
	 */
	redrawPointerLines: function () {
		this.fadeOutPointerLines( false );
		$( '.mw-revslider-pointer-line-upper, .mw-revslider-pointer-line-lower' )
			.removeClass( 'mw-revslider-bottom-line mw-revslider-left-line mw-revslider-right-line' );
		this.pointerOlder.getLine().drawLine();
		this.pointerNewer.getLine().drawLine();
	},

	/**
	 * @private
	 * @param {boolean} fade
	 */
	fadeOutPointerLines: function ( fade ) {
		$( '.mw-revslider-pointer-line' ).css( 'opacity', fade ? 0.3 : '' );
	},

	/**
	 * @private
	 * @return {number}
	 */
	calculateSliderContainerWidth: function () {
		return Math.min(
			this.slider.getRevisionList().getLength(),
			utils.calculateRevisionsPerWindow( this.containerMargin + this.outerMargin, this.revisionWidth )
		) * this.revisionWidth;
	},

	/**
	 * Slide the view to the next chunk of older / newer revisions
	 *
	 * @param {number} direction - Either -1, 0 or 1
	 * @param {number|string} [duration]
	 */
	slideView: function ( direction, duration ) {
		const $animatedElement = this.$element.find( '.mw-revslider-revisions-container' );

		this.slider.slide( direction );
		this.pointerOlder.getView().getElement().draggable( 'disable' );
		this.pointerNewer.getView().getElement().draggable( 'disable' );

		this.backwardArrowButton.setDisabled( this.slider.isAtStart() );
		this.forwardArrowButton.setDisabled( this.slider.isAtEnd() );

		$animatedElement.animate(
			{ scrollLeft: this.getScrollLeft() },
			duration,
			null,
			() => {
				this.pointerOlder.getView().getElement().draggable( 'enable' );
				this.pointerNewer.getView().getElement().draggable( 'enable' );

				if ( this.slider.isAtStart() && !this.noMoreOlderRevisions ) {
					this.addOlderRevisionsIfNeeded( $( '.mw-revslider-revision-slider' ) );
				}
				if ( this.slider.isAtEnd() && !this.noMoreNewerRevisions ) {
					this.addNewerRevisionsIfNeeded( $( '.mw-revslider-revision-slider' ) );
				}

				this.resetRevisionStylesBasedOnPointerPosition(
					this.getRevisionsElement()
				);
			}
		);

		this.alignPointersAndLines( duration );
	},

	/**
	 * @private
	 * @return {number}
	 */
	getScrollLeft: function () {
		const scrollLeft = this.slider.getOldestVisibleRevisionIndex() * this.revisionWidth;
		// See https://github.com/othree/jquery.rtl-scroll-type for why we don't need anything else
		// anymore.
		return this.dir === 'rtl' ? -scrollLeft : scrollLeft;
	},

	/**
	 * Visually move pointers to the positions set and reset pointer- and slider-lines
	 *
	 * @private
	 * @param {number|string} [duration]
	 */
	alignPointersAndLines: function ( duration ) {
		this.fadeOutPointerLines( true );

		this.pointerOlder.getView()
			.slideToSideOrPosition( this.slider, duration )
			.promise().then( () => {
				this.resetSliderLines();
				this.redrawPointerLines();
			} );
		this.pointerNewer.getView()
			.slideToSideOrPosition( this.slider, duration )
			.promise().then( () => {
				this.resetSliderLines();
				this.redrawPointerLines();
			} );
	},

	/**
	 * Returns the Pointer object that belongs to the passed element
	 *
	 * @private
	 * @param {jQuery} $e
	 * @return {Pointer}
	 */
	whichPointer: function ( $e ) {
		return $e.hasClass( 'mw-revslider-pointer-older' ) ? this.pointerOlder : this.pointerNewer;
	},

	/**
	 * @private
	 * @param {jQuery} $slider
	 */
	addNewerRevisionsIfNeeded: function ( $slider ) {
		const api = new RevisionSliderApi( mw.util.wikiScript( 'api' ) ),
			revisions = this.slider.getRevisionList().getRevisions(),
			revisionCount = utils.calculateRevisionsPerWindow( this.containerMargin + this.outerMargin, this.revisionWidth );
		if ( this.noMoreNewerRevisions || !this.slider.isAtEnd() ) {
			return;
		}
		api.fetchRevisionData( mw.config.get( 'wgPageName' ), {
			startId: revisions[ revisions.length - 1 ].getId(),
			dir: 'newer',
			limit: revisionCount + 1,
			knownUserGenders: this.slider.getRevisionList().getUserGenders(),
			changeTags: this.slider.getRevisionList().getAvailableTags()
		} ).then( ( data ) => {
			const revs = data.revisions.slice( 1 );
			if ( !revs.length ) {
				this.noMoreNewerRevisions = true;
				return;
			}

			this.addRevisionsAtEnd( $slider, revs );

			if ( !( 'continue' in data ) ) {
				this.noMoreNewerRevisions = true;
			}
		} );
	},

	/**
	 * @private
	 * @param {jQuery} $slider
	 */
	addOlderRevisionsIfNeeded: function ( $slider ) {
		const api = new RevisionSliderApi( mw.util.wikiScript( 'api' ) ),
			revisions = this.slider.getRevisionList().getRevisions(),
			revisionCount = utils.calculateRevisionsPerWindow( this.containerMargin + this.outerMargin, this.revisionWidth );
		let precedingRevisionSize = 0;
		if ( this.noMoreOlderRevisions || !this.slider.isAtStart() ) {
			return;
		}
		api.fetchRevisionData( mw.config.get( 'wgPageName' ), {
			startId: revisions[ 0 ].getId(),
			dir: 'older',
			// fetch an extra revision if there are more older revision than the current "window",
			// this makes it possible to correctly set a size of the bar related to the oldest revision to add
			limit: revisionCount + 2,
			knownUserGenders: this.slider.getRevisionList().getUserGenders(),
			changeTags: this.slider.getRevisionList().getAvailableTags()
		} ).then( ( data ) => {
			let revs = data.revisions.slice( 1 ).reverse();
			if ( !revs.length ) {
				this.noMoreOlderRevisions = true;
				return;
			}

			if ( revs.length === revisionCount + 1 ) {
				precedingRevisionSize = revs[ 0 ].size;
				revs = revs.slice( 1 );
			}
			this.addRevisionsAtStart( $slider, revs, precedingRevisionSize );

			if ( !( 'continue' in data ) ) {
				this.noMoreOlderRevisions = true;
			}
		} );
	},

	/**
	 * @private
	 * @param {jQuery} $slider
	 * @param {Array} revs
	 */
	addRevisionsAtEnd: function ( $slider, revs ) {
		const revPositionOffset = this.slider.getRevisionList().getLength(),
			$revisions = $slider.find( '.mw-revslider-revisions-container .mw-revslider-revisions' );

		this.slider.getRevisionList().push( makeRevisions( revs ) );

		// Pushed revisions have their relative sizes set correctly with regard to the last previously
		// loaded revision. This should be taken into account when rendering newly loaded revisions (tooltip)
		const revisionsToRender = this.slider.getRevisionList().slice( revPositionOffset );

		const $addedRevisions = new RevisionListView( revisionsToRender ).render( this.revisionWidth, revPositionOffset );
		this.addClickHandlerToRevisions( $addedRevisions );

		$addedRevisions.find( '.mw-revslider-revision-wrapper' ).each( function () {
			$revisions.append( $( this ) );
		} );

		if ( this.shouldExpandSlider( $slider ) ) {
			this.expandSlider( $slider );
		}

		this.getRevisionListView().adjustRevisionSizes( $slider );

		if ( !this.slider.isAtEnd() ) {
			this.forwardArrowButton.setDisabled( false );
		}
	},

	/**
	 * @private
	 * @param {jQuery} $slider
	 * @param {Array} revs
	 * @param {number} precedingRevisionSize optional size of the revision preceding the first of revs,
	 *                                        used to correctly determine first revision's relative size
	 */
	addRevisionsAtStart: function ( $slider, revs, precedingRevisionSize ) {
		const $revisions = $slider.find( '.mw-revslider-revisions-container .mw-revslider-revisions' );
		const $revisionContainer = $slider.find( '.mw-revslider-revisions-container' );
		let revisionStyleResetRequired = false;

		this.slider.getRevisionList().unshift( makeRevisions( revs ), precedingRevisionSize );

		$slider.find( '.mw-revslider-revision' ).each( function () {
			$( this ).attr( 'data-pos', parseInt( $( this ).attr( 'data-pos' ), 10 ) + revs.length );
		} );

		// Pushed (unshifted) revisions have their relative sizes set correctly with regard to the last previously
		// loaded revision. This should be taken into account when rendering newly loaded revisions (tooltip)
		const revisionsToRender = this.slider.getRevisionList().slice( 0, revs.length );

		const $addedRevisions = new RevisionListView( revisionsToRender ).render( this.revisionWidth );
		this.addClickHandlerToRevisions( $addedRevisions );

		if ( this.getOlderPointerPos() !== -1 ) {
			this.setOlderPointerPos( this.getOlderPointerPos() + revisionsToRender.getLength() );
		} else {
			// Special case: old revision has been previously not loaded, need to initialize correct position
			const $oldRevElement = this.getOldRevElement( $addedRevisions );
			if ( $oldRevElement.length !== 0 ) {
				this.setOlderPointerPos( +$oldRevElement.attr( 'data-pos' ) );
				revisionStyleResetRequired = true;
			}

		}
		this.setNewerPointerPos( this.getNewerPointerPos() + revisionsToRender.getLength() );

		$( $addedRevisions.find( '.mw-revslider-revision-wrapper' ).get().reverse() ).each( function () { // TODO: this is horrible
			$revisions.prepend( $( this ) );
		} );

		if ( revisionStyleResetRequired ) {
			this.resetRevisionStylesBasedOnPointerPosition( $slider );
		}

		this.slider.setFirstVisibleRevisionIndex( this.slider.getOldestVisibleRevisionIndex() + revisionsToRender.getLength() );

		const revIdOld = +this.getRevElementAtPosition( $revisions, this.getOlderPointerPos() ).attr( 'data-revid' );
		const revIdNew = +this.getRevElementAtPosition( $revisions, this.getNewerPointerPos() ).attr( 'data-revid' );
		this.diffPage.replaceState( revIdNew, revIdOld, this );

		$revisionContainer.scrollLeft( this.getScrollLeft() );

		if ( this.shouldExpandSlider( $slider ) ) {
			this.expandSlider( $slider );
		}

		this.getRevisionListView().adjustRevisionSizes( $slider );

		this.backwardArrowButton.setDisabled( false );
	},

	/**
	 * @private
	 * @param {jQuery} $slider
	 * @return {boolean}
	 */
	shouldExpandSlider: function ( $slider ) {
		const sliderWidth = $slider.width(),
			maxAvailableWidth = this.calculateSliderContainerWidth() + this.containerMargin;

		return !( this.noMoreNewerRevisions && this.noMoreOlderRevisions ) && sliderWidth < maxAvailableWidth;
	},

	/**
	 * @private
	 * @param {jQuery} $slider
	 */
	expandSlider: function ( $slider ) {
		const containerWidth = this.calculateSliderContainerWidth();

		$slider.css( { width: ( containerWidth + this.containerMargin ) + 'px' } );
		$slider.find( '.mw-revslider-revisions-container' ).css( { width: containerWidth + 'px' } );
		$slider.find( '.mw-revslider-pointer-container' ).css( { width: containerWidth + this.revisionWidth - 1 + 'px' } );

		const expandedRevisionWindowCapacity = $slider.find( '.mw-revslider-revisions-container' ).width() / this.revisionWidth;
		this.slider.setRevisionsPerWindow( expandedRevisionWindowCapacity );

		this.slideView( Math.floor( ( this.getNewerPointerPos() - 1 ) / expandedRevisionWindowCapacity ), 0 );
	},

	/**
	 * @private
	 * @return {RevisionListView}
	 */
	getRevisionListView: function () {
		return this.slider.getRevisionList().getView();
	},

	/**
	 * @private
	 * @return {jQuery}
	 */
	getRevisionsElement: function () {
		return this.getRevisionListView().getElement();
	}
} );

module.exports = SliderView;
