/*
 * This file is part of the MediaWiki extension MultimediaViewer.
 *
 * MultimediaViewer is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 2 of the License, or
 * (at your option) any later version.
 *
 * MultimediaViewer is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with MultimediaViewer.  If not, see <http://www.gnu.org/licenses/>.
 */

( function () {
	var router = require( 'mediawiki.router' );
	var comingFromHashChange = false;
	var MMVP;

	/**
	 * Analyses the page, looks for image content and sets up the hooks
	 * to manage the viewing experience of such content.
	 *
	 * @class mw.mmv.MultimediaViewer
	 * @constructor
	 * @param {mw.mmv.Config} config mw.mmv.Config object
	 */
	function MultimediaViewer( config ) {
		var apiCacheMaxAge = 86400, // one day (24 hours * 60 min * 60 sec)
			apiCacheFiveMinutes = 300, // 5 min * 60 sec
			api = new mw.Api();

		/**
		 * @property {mw.mmv.Config}
		 * @private
		 */
		this.config = config;

		/**
		 * @property {mw.mmv.provider.Image}
		 * @private
		 */
		this.imageProvider = new mw.mmv.provider.Image( this.config.imageQueryParameter() );

		/**
		 * @property {mw.mmv.provider.ImageInfo}
		 * @private
		 */
		this.imageInfoProvider = new mw.mmv.provider.ImageInfo( api, {
			language: this.config.language(),
			maxage: apiCacheFiveMinutes
		} );

		/**
		 * @property {mw.mmv.provider.FileRepoInfo}
		 * @private
		 */
		this.fileRepoInfoProvider = new mw.mmv.provider.FileRepoInfo( api,
			{ maxage: apiCacheMaxAge } );

		/**
		 * @property {mw.mmv.provider.ThumbnailInfo}
		 * @private
		 */
		this.thumbnailInfoProvider = new mw.mmv.provider.ThumbnailInfo( api,
			{ maxage: apiCacheMaxAge } );

		/**
		 * @property {mw.mmv.provider.ThumbnailInfo}
		 * @private
		 */
		this.guessedThumbnailInfoProvider = new mw.mmv.provider.GuessedThumbnailInfo();

		/**
		 * Image index on page.
		 *
		 * @property {number}
		 */
		this.currentIndex = 0;

		/**
		 * @property {OO.Router} router
		 */
		this.router = router;
		this.setupRouter();
		comingFromHashChange = false;

		/**
		 * UI object used to display the pictures in the page.
		 *
		 * @property {mw.mmv.LightboxInterface}
		 * @private
		 */
		this.ui = new mw.mmv.LightboxInterface();

		/**
		 * How many sharp images have been displayed in Media Viewer since the pageload
		 *
		 * @property {number}
		 */
		this.imageDisplayedCount = 0;

		/**
		 * How many data-filled metadata panels have been displayed in Media Viewer since the pageload
		 *
		 * @property {number}
		 */
		this.metadataDisplayedCount = 0;

		/** @property {string} documentTitle base document title, MediaViewer will expand this */
		this.documentTitle = document.title;

		/**
		 * @property {mw.mmv.logging.ViewLogger} view -
		 */
		this.viewLogger = new mw.mmv.logging.ViewLogger( this.config, window );

		/**
		 * Stores whether the real image was loaded and displayed already.
		 * This is reset when paging, so it is not necessarily accurate.
		 *
		 * @property {boolean}
		 */
		this.realThumbnailShown = false;

		/**
		 * Stores whether the a blurred placeholder is being displayed in place of the real image.
		 * When a placeholder is displayed, but it is not blurred, this is false.
		 * This is reset when paging, so it is not necessarily accurate.
		 *
		 * @property {boolean}
		 */
		this.blurredThumbnailShown = false;
	}

	MMVP = MultimediaViewer.prototype;

	/**
	 * Initialize the lightbox interface given an array of thumbnail
	 * objects.
	 *
	 * @param {Object[]} thumbs Complex structure...TODO, document this better.
	 */
	MMVP.initWithThumbs = function ( thumbs ) {
		var i, thumb;

		this.thumbs = thumbs;

		for ( i = 0; i < this.thumbs.length; i++ ) {
			thumb = this.thumbs[ i ];
			// Create a LightboxImage object for each legit image
			thumb.image = this.createNewImage(
				thumb.$thumb.prop( 'src' ),
				thumb.link,
				thumb.title,
				i,
				thumb.thumb,
				thumb.caption,
				thumb.alt
			);

			thumb.extraStatsDeferred = $.Deferred();
		}
	};

	/**
	 * Create an image object for the lightbox to use.
	 *
	 * @protected
	 * @param {string} fileLink Link to the file - generally a thumb URL
	 * @param {string} filePageLink Link to the File: page
	 * @param {mw.Title} fileTitle Represents the File: page
	 * @param {number} index Which number file this is
	 * @param {HTMLImageElement} thumb The thumbnail that represents this image on the page
	 * @param {string} [caption] The caption, if any.
	 * @param {string} [alt] The alt text of the image
	 * @return {mw.mmv.LightboxImage}
	 */
	MMVP.createNewImage = function ( fileLink, filePageLink, fileTitle, index, thumb, caption, alt ) {
		var thisImage = new mw.mmv.LightboxImage( fileLink, filePageLink, fileTitle, index, thumb, caption, alt ),
			$thumb = $( thumb );

		thisImage.filePageLink = filePageLink;
		thisImage.filePageTitle = fileTitle;
		thisImage.index = index;
		thisImage.thumbnail = thumb;
		thisImage.originalWidth = parseInt( $thumb.data( 'file-width' ), 10 );
		thisImage.originalHeight = parseInt( $thumb.data( 'file-height' ), 10 );

		return thisImage;
	};

	/**
	 * Handles resize events in viewer.
	 *
	 * @protected
	 * @param {mw.mmv.LightboxInterface} ui lightbox that got resized
	 */
	MMVP.resize = function ( ui ) {
		var imageWidths,
			viewer = this,
			image = this.thumbs[ this.currentIndex ].image,
			ext = this.thumbs[ this.currentIndex ].title.getExtension().toLowerCase();

		this.preloadThumbnails();

		if ( image ) {
			imageWidths = ui.canvas.getCurrentImageWidths();

			this.fetchThumbnailForLightboxImage(
				image, imageWidths.real
			).then( function ( thumbnail, image2 ) {
				// eslint-disable-next-line mediawiki/class-doc
				image2.className = ext;
				viewer.setImage( ui, thumbnail, image2, imageWidths );
			}, function ( error ) {
				viewer.ui.canvas.showError( error );
			} );
		}

		this.updateControls();
	};

	/**
	 * Updates positioning of controls, usually after a resize event.
	 */
	MMVP.updateControls = function () {
		var numImages = this.thumbs ? this.thumbs.length : 0,
			showNextButton = this.currentIndex < ( numImages - 1 ),
			showPreviousButton = this.currentIndex > 0;

		this.ui.updateControls( showNextButton, showPreviousButton );
	};

	/**
	 * Loads and sets the specified image. It also updates the controls.
	 *
	 * @param {mw.mmv.LightboxInterface} ui image container
	 * @param {mw.mmv.model.Thumbnail} thumbnail thumbnail information
	 * @param {HTMLImageElement} imageElement
	 * @param {mw.mmv.model.ThumbnailWidth} imageWidths
	 */
	MMVP.setImage = function ( ui, thumbnail, imageElement, imageWidths ) {
		ui.canvas.setImageAndMaxDimensions( thumbnail, imageElement, imageWidths );
		this.updateControls();
	};

	/**
	 * Loads a specified image.
	 *
	 * @param {mw.mmv.LightboxImage} image
	 * @param {HTMLImageElement} initialImage A thumbnail to use as placeholder while the image loads
	 * @param {boolean} useReplaceState Whether to update history entry to avoid long history queues
	 */
	MMVP.loadImage = function ( image, initialImage, useReplaceState ) {
		var imageWidths,
			imagePromise,
			metadataPromise,
			pluginsPromise,
			start,
			viewer = this,
			$initialImage = $( initialImage ),
			extraStatsDeferred = $.Deferred();

		pluginsPromise = this.loadExtensionPlugins( image.filePageTitle.getExtension().toLowerCase() );

		this.currentIndex = image.index;

		this.currentImageFileTitle = image.filePageTitle;

		if ( !this.isOpen ) {
			$( document ).trigger( $.Event( 'mmv-setup-overlay' ) );
			this.ui.open();
			this.isOpen = true;
		} else {
			this.ui.empty();
		}

		this.setMediaHash( useReplaceState );

		// At this point we can't show the thumbnail because we don't
		// know what size it should be. We still assign it to allow for
		// size calculations in getCurrentImageWidths, which needs to know
		// the aspect ratio
		// eslint-disable-next-line mediawiki/class-doc
		$initialImage.hide()
			.removeAttr( 'style' )
			.removeClass()
			.addClass( 'mw-mmv-placeholder-image ' + image.filePageTitle.getExtension().toLowerCase() );

		this.ui.canvas.set( image, $initialImage );

		this.preloadImagesMetadata();
		this.preloadThumbnails();
		// this.preloadFullscreenThumbnail( image ); // disabled - #474

		imageWidths = this.ui.canvas.getCurrentImageWidths();

		start = Date.now();

		imagePromise = this.fetchThumbnailForLightboxImage( image, imageWidths.real, extraStatsDeferred );

		this.resetBlurredThumbnailStates();
		if ( imagePromise.state() === 'pending' ) {
			this.displayPlaceholderThumbnail( image, $initialImage, imageWidths );
		}

		this.setupProgressBar( image, imagePromise, imageWidths.real );

		metadataPromise = this.fetchSizeIndependentLightboxInfo( image.filePageTitle );

		imagePromise.then(
			// done
			function ( thumbnail, imageElement ) {
				if ( viewer.currentIndex !== image.index ) {
					return;
				}

				// eslint-disable-next-line mediawiki/class-doc
				imageElement.className = 'mw-mmv-final-image ' + image.filePageTitle.getExtension().toLowerCase();
				imageElement.alt = image.alt;

				$.when( metadataPromise, pluginsPromise ).then( function ( metadata ) {
					$( document ).trigger( $.Event( 'mmv-metadata', { viewer: viewer, image: image, imageInfo: metadata[ 0 ] } ) );
				} );

				viewer.displayRealThumbnail( thumbnail, imageElement, imageWidths, Date.now() - start );

				return $.Deferred().resolve( thumbnail, imageElement );
			},
			// fail
			function ( error ) {
				viewer.ui.canvas.showError( error );
				return $.Deferred().reject( error );
			}
		);

		metadataPromise.then(
			// done
			function ( imageInfo, repoInfo ) {
				extraStatsDeferred.resolve( { uploadTimestamp: imageInfo.anonymizedUploadDateTime } );

				if ( viewer.currentIndex !== image.index ) {
					return;
				}

				viewer.ui.panel.setImageInfo( image, imageInfo, repoInfo );

				// File reuse steals a bunch of information from the DOM, so do it last
				viewer.ui.setFileReuseData( imageInfo, repoInfo, image.caption, image.alt );

				return $.Deferred().resolve( imageInfo, repoInfo );
			},
			// fail
			function ( error ) {
				extraStatsDeferred.reject();

				if ( viewer.currentIndex === image.index ) {
					// Set title to caption or file name if caption is not available;
					// see setTitle() in mmv.ui.metadataPanel for extended caption fallback
					viewer.ui.panel.showError( image.caption || image.filePageTitle.getNameText(), error );
				}

				return $.Deferred().reject( error );
			}
		);

		$.when( imagePromise, metadataPromise ).then( function () {
			if ( viewer.currentIndex !== image.index ) {
				return;
			}

			viewer.ui.panel.scroller.animateMetadataOnce();
			viewer.preloadDependencies();
		} );
	};

	/**
	 * Loads an image by its title
	 *
	 * @param {mw.Title} title
	 * @param {boolean} useReplaceState Whether to update history entry to avoid long history queues
	 */
	MMVP.loadImageByTitle = function ( title, useReplaceState ) {
		var i, thumb;

		if ( !this.thumbs || !this.thumbs.length ) {
			return;
		}

		for ( i = 0; i < this.thumbs.length; i++ ) {
			thumb = this.thumbs[ i ];
			if ( thumb.title.getPrefixedText() === title.getPrefixedText() ) {
				this.loadImage( thumb.image, thumb.$thumb.clone()[ 0 ], useReplaceState );
				return;
			}
		}
	};

	/**
	 * Image loading progress. Keyed by image (database) name + '|' + thumbnail width in pixels,
	 * value is undefined, 'blurred' or 'real' (meaning respectively that no thumbnail is shown
	 * yet / the thumbnail that existed on the page is shown, enlarged and blurred / the real,
	 * correct-size thumbnail is shown).
	 *
	 * @private
	 * @property {Object.<string, string>}
	 */
	MMVP.thumbnailStateCache = {};

	/**
	 * Resets the cross-request states needed to handle the blurred thumbnail logic.
	 */
	MMVP.resetBlurredThumbnailStates = function () {
		this.realThumbnailShown = false;
		this.blurredThumbnailShown = false;
	};

	/**
	 * Display the real, full-resolution, thumbnail that was fetched with fetchThumbnail
	 *
	 * @param {mw.mmv.model.Thumbnail} thumbnail
	 * @param {HTMLImageElement} imageElement
	 * @param {mw.mmv.model.ThumbnailWidth} imageWidths
	 * @param {number} loadTime Time it took to load the thumbnail
	 */
	MMVP.displayRealThumbnail = function ( thumbnail, imageElement, imageWidths, loadTime ) {
		this.realThumbnailShown = true;

		this.setImage( this.ui, thumbnail, imageElement, imageWidths );

		// We only animate unblurWithAnimation if the image wasn't loaded from the cache
		// A load in < 100ms is fast enough (maybe even browser cache hit) that
		// using a 300ms animation would needlessly deter from a fast experience.
		if ( this.blurredThumbnailShown && loadTime > 100 ) {
			this.ui.canvas.unblurWithAnimation();
		} else {
			this.ui.canvas.unblur();
		}

		this.viewLogger.attach( thumbnail.url );
	};

	/**
	 * Display the blurred thumbnail from the page
	 *
	 * @param {mw.mmv.LightboxImage} image
	 * @param {jQuery} $initialImage The thumbnail from the page
	 * @param {mw.mmv.model.ThumbnailWidth} imageWidths
	 * @param {boolean} [recursion=false] for internal use, never set this when calling from outside
	 */
	MMVP.displayPlaceholderThumbnail = function ( image, $initialImage, imageWidths, recursion ) {
		var viewer = this,
			size = { width: image.originalWidth, height: image.originalHeight };

		// If the actual image has already been displayed, there's no point showing the blurry one.
		// This can happen if the API request to get the original image size needed to show the
		// placeholder thumbnail takes longer then loading the actual thumbnail.
		if ( this.realThumbnailShown ) {
			return;
		}

		// Width/height of the original image are added to the HTML by MediaViewer via a PHP hook,
		// and can be missing in exotic circumstances, e. g. when the extension has only been
		// enabled recently and the HTML cache has not cleared yet. If that is the case, we need
		// to fetch the size from the API first.
		if ( !size.width || !size.height ) {
			if ( recursion ) {
				// this should not be possible, but an infinite recursion is nasty
				// business, so we make a sense check
				throw new Error( 'MediaViewer internal error: displayPlaceholderThumbnail recursion' );
			}
			this.imageInfoProvider.get( image.filePageTitle ).done( function ( imageInfo ) {
				// Make sure the user has not navigated away while we were waiting for the size
				if ( viewer.currentIndex === image.index ) {
					image.originalWidth = imageInfo.width;
					image.originalHeight = imageInfo.height;
					viewer.displayPlaceholderThumbnail( image, $initialImage, imageWidths, true );
				}
			} );
		} else {
			this.blurredThumbnailShown = this.ui.canvas.maybeDisplayPlaceholder(
				size, $initialImage, imageWidths );
		}
	};

	/**
	 * Image loading progress. Keyed by image (database) name + '|' + thumbnail width in pixels,
	 * value is a number between 0-100.
	 *
	 * @private
	 * @property {Object.<string, number>}
	 */
	MMVP.progressCache = {};

	/**
	 * Displays a progress bar for the image loading, if necessary, and sets up handling of
	 * all the related callbacks.
	 *
	 * @param {mw.mmv.LightboxImage} image
	 * @param {jQuery.Promise.<mw.mmv.model.Thumbnail, HTMLImageElement>} imagePromise
	 * @param {number} imageWidth needed for caching progress (FIXME)
	 */
	MMVP.setupProgressBar = function ( image, imagePromise, imageWidth ) {
		var viewer = this,
			progressBar = viewer.ui.panel.progressBar,
			key = image.filePageTitle.getPrefixedDb() + '|' + imageWidth;

		if ( !this.progressCache[ key ] ) {
			// Animate progress bar to 5 to give a sense that something is happening, and make sure
			// the progress bar is noticeable, even if we're sitting at 0% stuck waiting for
			// server-side processing, such as thumbnail (re)generation
			progressBar.jumpTo( 0 );
			progressBar.animateTo( 5 );
			viewer.progressCache[ key ] = 5;
		} else {
			progressBar.jumpTo( this.progressCache[ key ] );
		}

		// FIXME would be nice to have a "filtered" promise which does not fire when the image is not visible
		imagePromise.then(
			// done
			function ( thumbnail, imageElement ) {
				viewer.progressCache[ key ] = 100;
				if ( viewer.currentIndex === image.index ) {
					// Fallback in case the browser doesn't have fancy progress updates
					progressBar.animateTo( 100 );

					// Hide progress bar, we're done
					progressBar.hide();
				}

				return $.Deferred().resolve( thumbnail, imageElement );
			},
			// fail
			function ( error ) {
				viewer.progressCache[ key ] = 100;

				if ( viewer.currentIndex === image.index ) {
					// Hide progress bar on error
					progressBar.hide();
				}

				return $.Deferred().reject( error );
			},
			// progress
			function ( progress ) {
				// We pretend progress is always at least 5%, so progress events below 5% should be ignored
				// 100 will be handled by the done handler, do not mix two animations
				if ( progress >= 5 && progress < 100 ) {
					viewer.progressCache[ key ] = progress;

					// Touch the UI only if the user is looking at this image
					if ( viewer.currentIndex === image.index ) {
						progressBar.animateTo( progress );
					}
				}

				return progress;
			}
		);
	};

	/**
	 * Preload this many prev/next images to speed up navigation.
	 * (E.g. preloadDistance = 3 means that the previous 3 and the next 3 images will be loaded.)
	 * Preloading only happens when the viewer is open.
	 *
	 * @property {number}
	 */
	MMVP.preloadDistance = 1;

	/**
	 * Stores image metadata preloads, so they can be cancelled.
	 *
	 * @property {mw.mmv.model.TaskQueue}
	 */
	MMVP.metadataPreloadQueue = null;

	/**
	 * Stores image thumbnail preloads, so they can be cancelled.
	 *
	 * @property {mw.mmv.model.TaskQueue}
	 */
	MMVP.thumbnailPreloadQueue = null;

	/**
	 * Orders lightboximage indexes for preloading. Works similar to $.each, except it only takes
	 * the callback argument. Calls the callback with each lightboximage index in some sequence
	 * that is ideal for preloading.
	 *
	 * @private
	 * @param {function(number, mw.mmv.LightboxImage)} callback
	 */
	MMVP.eachPreloadableLightboxIndex = function ( callback ) {
		var i;
		for ( i = 0; i <= this.preloadDistance; i++ ) {
			if ( this.currentIndex + i < this.thumbs.length ) {
				callback(
					this.currentIndex + i,
					this.thumbs[ this.currentIndex + i ].image,
					this.thumbs[ this.currentIndex + i ].extraStatsDeferred
				);
			}
			if ( i && this.currentIndex - i >= 0 ) { // skip duplicate for i==0
				callback(
					this.currentIndex - i,
					this.thumbs[ this.currentIndex - i ].image,
					this.thumbs[ this.currentIndex - i ].extraStatsDeferred
				);
			}
		}
	};

	/**
	 * A helper function to fill up the preload queues.
	 * taskFactory(lightboxImage) should return a preload task for the given lightboximage.
	 *
	 * @private
	 * @param {function(mw.mmv.LightboxImage): function()} taskFactory
	 * @return {mw.mmv.model.TaskQueue}
	 */
	MMVP.pushLightboxImagesIntoQueue = function ( taskFactory ) {
		var queue = new mw.mmv.model.TaskQueue();

		this.eachPreloadableLightboxIndex( function ( i, lightboxImage, extraStatsDeferred ) {
			queue.push( taskFactory( lightboxImage, extraStatsDeferred ) );
		} );

		return queue;
	};

	/**
	 * Cancels in-progress image metadata preloading.
	 */
	MMVP.cancelImageMetadataPreloading = function () {
		if ( this.metadataPreloadQueue ) {
			this.metadataPreloadQueue.cancel();
		}
	};

	/**
	 * Cancels in-progress image thumbnail preloading.
	 */
	MMVP.cancelThumbnailsPreloading = function () {
		if ( this.thumbnailPreloadQueue ) {
			this.thumbnailPreloadQueue.cancel();
		}
	};

	/**
	 * Preload metadata for next and prev N image (N = MMVP.preloadDistance).
	 * Two images will be loaded at a time (one forward, one backward), with closer images
	 * being loaded sooner.
	 */
	MMVP.preloadImagesMetadata = function () {
		var viewer = this;

		this.cancelImageMetadataPreloading();

		this.metadataPreloadQueue = this.pushLightboxImagesIntoQueue( function ( lightboxImage, extraStatsDeferred ) {
			return function () {
				var metadataPromise = viewer.fetchSizeIndependentLightboxInfo( lightboxImage.filePageTitle );
				metadataPromise.done( function ( imageInfo ) {
					extraStatsDeferred.resolve( { uploadTimestamp: imageInfo.anonymizedUploadDateTime } );
				} ).fail( function () {
					extraStatsDeferred.reject();
				} );
				return metadataPromise;
			};
		} );

		this.metadataPreloadQueue.execute();
	};

	/**
	 * Preload thumbnails for next and prev N image (N = MMVP.preloadDistance).
	 * Two images will be loaded at a time (one forward, one backward), with closer images
	 * being loaded sooner.
	 */
	MMVP.preloadThumbnails = function () {
		var viewer = this;

		this.cancelThumbnailsPreloading();

		this.thumbnailPreloadQueue = this.pushLightboxImagesIntoQueue( function ( lightboxImage, extraStatsDeferred ) {
			return function () {
				var imageWidths;

				// viewer.ui.canvas.getLightboxImageWidths needs the viewer to be open
				// because it needs to read the size of visible elements
				if ( !viewer.isOpen ) {
					return;
				}

				imageWidths = viewer.ui.canvas.getLightboxImageWidths( lightboxImage );

				return viewer.fetchThumbnailForLightboxImage( lightboxImage, imageWidths.real, extraStatsDeferred );
			};
		} );

		this.thumbnailPreloadQueue.execute();
	};

	/**
	 * Preload the fullscreen size of the current image.
	 *
	 * @param {mw.mmv.LightboxImage} image
	 */
	MMVP.preloadFullscreenThumbnail = function ( image ) {
		var imageWidths = this.ui.canvas.getLightboxImageWidthsForFullscreen( image );

		this.fetchThumbnailForLightboxImage( image, imageWidths.real );
	};

	/**
	 * Loads all the size-independent information needed by the lightbox (image metadata, repo
	 * information).
	 *
	 * @param {mw.Title} fileTitle Title of the file page for the image.
	 * @return {jQuery.Promise.<mw.mmv.model.Image, mw.mmv.model.Repo>}
	 */
	MMVP.fetchSizeIndependentLightboxInfo = function ( fileTitle ) {
		var imageInfoPromise = this.imageInfoProvider.get( fileTitle ),
			repoInfoPromise = this.fileRepoInfoProvider.get( fileTitle );

		return $.when(
			imageInfoPromise, repoInfoPromise
		).then( function ( imageInfo, repoInfoHash ) {
			return $.Deferred().resolve( imageInfo, repoInfoHash[ imageInfo.repo ] );
		} );
	};

	/**
	 * Loads size-dependent components of a lightbox - the thumbnail model and the image itself.
	 *
	 * @param {mw.mmv.LightboxImage} image
	 * @param {number} width the width of the requested thumbnail
	 * @param {jQuery.Deferred.<string>} [extraStatsDeferred] Promise that resolves to the image's upload timestamp when the metadata is loaded
	 * @return {jQuery.Promise.<mw.mmv.model.Thumbnail, HTMLImageElement>}
	 */
	MMVP.fetchThumbnailForLightboxImage = function ( image, width, extraStatsDeferred ) {
		return this.fetchThumbnail(
			image.filePageTitle,
			width,
			image.src,
			image.originalWidth,
			image.originalHeight,
			extraStatsDeferred
		);
	};

	/**
	 * Loads size-dependent components of a lightbox - the thumbnail model and the image itself.
	 *
	 * @param {mw.Title} fileTitle
	 * @param {number} width the width of the requested thumbnail
	 * @param {string} [sampleUrl] a thumbnail URL for the same file (but with different size) (might be missing)
	 * @param {number} [originalWidth] the width of the original, full-sized file (might be missing)
	 * @param {number} [originalHeight] the height of the original, full-sized file (might be missing)
	 * @param {jQuery.Deferred.<string>} [extraStatsDeferred] Promise that resolves to the image's upload timestamp when the metadata is loaded
	 * @return {jQuery.Promise.<mw.mmv.model.Thumbnail, HTMLImageElement>} A promise resolving to
	 *  a thumbnail model and an <img> element. It might or might not have progress events which
	 *  return a single number.
	 */
	MMVP.fetchThumbnail = function ( fileTitle, width, sampleUrl, originalWidth, originalHeight, extraStatsDeferred ) {
		var viewer = this,
			guessing = false,
			combinedDeferred = $.Deferred(),
			thumbnailPromise,
			imagePromise;

		if ( fileTitle.getExtension().toLowerCase() !== 'svg' && originalWidth && width > originalWidth ) {
			// Do not request images larger than the original image
			width = originalWidth;
		}

		if (
			sampleUrl && originalWidth && originalHeight &&
			this.config.useThumbnailGuessing()
		) {
			guessing = true;
			thumbnailPromise = this.guessedThumbnailInfoProvider.get(
				fileTitle, sampleUrl, width, originalWidth, originalHeight
			).then( null, function () { // catch rejection, use fallback
				return viewer.thumbnailInfoProvider.get( fileTitle, width );
			} );
		} else {
			thumbnailPromise = this.thumbnailInfoProvider.get( fileTitle, width );
		}

		// Add thumbnail width to the extra stats passed to the performance log
		extraStatsDeferred = $.when( extraStatsDeferred || {} ).then( function ( extraStats ) {
			extraStats.imageWidth = width;
			return extraStats;
		} );

		imagePromise = thumbnailPromise.then( function ( thumbnail ) {
			return viewer.imageProvider.get( thumbnail.url, extraStatsDeferred );
		} );

		if ( guessing ) {
			// If we guessed wrong, need to retry with real URL on failure.
			// As a side effect this introduces an extra (harmless) retry of a failed thumbnailInfoProvider.get call
			// because thumbnailInfoProvider.get is already called above when guessedThumbnailInfoProvider.get fails.
			imagePromise = imagePromise.then( null, function () {
				return viewer.thumbnailInfoProvider.get( fileTitle, width ).then( function ( thumbnail ) {
					return viewer.imageProvider.get( thumbnail.url, extraStatsDeferred );
				} );
			} );
		}

		// In jQuery<3, $.when used to also relay notify, but that is no longer
		// the case - but we still want to pass it along...
		$.when( thumbnailPromise, imagePromise ).then( combinedDeferred.resolve, combinedDeferred.reject );
		imagePromise.then( null, null, function ( arg, progress ) {
			combinedDeferred.notify( progress );
		} );
		return combinedDeferred;
	};

	/**
	 * Loads an image at a specified index in the viewer's thumbnail array.
	 *
	 * @param {number} index
	 */
	MMVP.loadIndex = function ( index ) {
		var thumb;

		if ( index < this.thumbs.length && index >= 0 ) {
			this.viewLogger.recordViewDuration();

			thumb = this.thumbs[ index ];
			this.loadImage( thumb.image, thumb.$thumb.clone()[ 0 ] );
		}
	};

	/**
	 * Opens the next image
	 */
	MMVP.nextImage = function () {
		this.loadIndex( this.currentIndex + 1 );
	};

	/**
	 * Opens the previous image
	 */
	MMVP.prevImage = function () {
		this.loadIndex( this.currentIndex - 1 );
	};

	/**
	 * Handles close event coming from the lightbox
	 */
	MMVP.close = function () {
		this.viewLogger.recordViewDuration();
		this.viewLogger.unattach();

		if ( comingFromHashChange ) {
			comingFromHashChange = false;
		} else {
			this.router.back();
		}
		// update title after route change, see T225387
		document.title = this.createDocumentTitle( null );

		// This has to happen after the hash reset, because setting the hash to # will reset the page scroll
		$( document ).trigger( $.Event( 'mmv-cleanup-overlay' ) );

		this.isOpen = false;
	};

	/**
	 * Sets up the route handlers
	 */
	MMVP.setupRouter = function () {
		function route( fileName ) {
			var fileTitle;
			comingFromHashChange = true;
			try {
				fileName = decodeURIComponent( fileName );
				fileTitle = new mw.Title( fileName );
				this.loadImageByTitle( fileTitle );
			} catch ( err ) {
				// ignore routes to invalid titles
				mw.log.warn( err );
			}
		}
		this.router.addRoute( mw.mmv.ROUTE_REGEXP, route.bind( this ) );
		this.router.addRoute( mw.mmv.LEGACY_ROUTE_REGEXP, route.bind( this ) );

		// handle empty hashes, and anchor links (page sections)
		this.router.addRoute( /^[^/]*$/, function () {
			if ( this.isOpen ) {
				comingFromHashChange = true;
				document.title = this.createDocumentTitle( null );
				if ( this.ui ) {
					// FIXME triggers mmv-close event, which calls viewer.close()
					this.ui.unattach();
				} else {
					this.close();
				}
			}
		}.bind( this ) );
	};

	/**
	 * Updates the hash to reflect an open image file
	 *
	 * @param {boolean} useReplaceState Whether to update history entry to avoid long history queues
	 */
	MMVP.setMediaHash = function ( useReplaceState ) {
		if ( useReplaceState === undefined ) {
			useReplaceState = true;
		}
		if ( comingFromHashChange ) {
			comingFromHashChange = false;
			return;
		}
		this.router.navigateTo( document.title, {
			path: mw.mmv.getMediaHash( this.currentImageFileTitle ),
			useReplaceState: useReplaceState
		} );
		// update title after route change, see T225387
		document.title = this.createDocumentTitle( this.currentImageFileTitle );
	};

	/**
	 * Creates a string which can be shown as document title (the text at the top of the browser window).
	 *
	 * @param {mw.Title|null} imageTitle the title object for the image which is displayed; null when the
	 *  viewer is being closed
	 * @return {string}
	 */
	MMVP.createDocumentTitle = function ( imageTitle ) {
		if ( imageTitle ) {
			return imageTitle.getNameText() + ' - ' + this.documentTitle;
		} else {
			return this.documentTitle;
		}
	};

	/**
	 * @event mmv-close
	 * Fired when the viewer is closed. This is used by the lightbox to notify the main app.
	 */
	/**
	 * @event mmv-next
	 * Fired when the user requests the next image.
	 */
	/**
	 * @event mmv-prev
	 * Fired when the user requests the previous image.
	 */
	/**
	 * @event mmv-resize-end
	 * Fired when the screen size changes. Debounced to avoid continuous triggering while resizing with a mouse.
	 */
	/**
	 * @event mmv-request-thumbnail
	 * Used by components to request a thumbnail URL for the current thumbnail, with a given size.
	 * @param {number} size
	 */
	/**
	 * Registers all event handlers
	 */
	MMVP.setupEventHandlers = function () {
		var viewer = this;

		this.ui.connect( this, {
			next: 'nextImage',
			prev: 'prevImage'
		} );

		$( document ).on( 'mmv-close.mmvp', function () {
			viewer.close();
		} ).on( 'mmv-resize-end.mmvp', function () {
			viewer.resize( viewer.ui );
		} ).on( 'mmv-request-thumbnail.mmvp', function ( e, size ) {
			if ( viewer.currentImageFileTitle ) {
				return viewer.thumbnailInfoProvider.get( viewer.currentImageFileTitle, size );
			} else {
				return $.Deferred().reject();
			}
		} ).on( 'mmv-viewfile.mmvp', function () {
			viewer.imageInfoProvider.get( viewer.currentImageFileTitle ).done( function ( imageInfo ) {
				document.location = imageInfo.url;
			} );
		} );
	};

	/**
	 * Unregisters all event handlers. Currently only used in tests.
	 */
	MMVP.cleanupEventHandlers = function () {
		$( document ).off( 'mmv-close.mmvp mmv-resize-end.mmvp' );

		this.ui.disconnect( this );
	};

	/**
	 * Preloads JS and CSS dependencies that aren't needed to display the first image, but could be needed later
	 */
	MMVP.preloadDependencies = function () {
		mw.loader.load( [ 'mmv.ui.reuse.shareembed' ] );
	};

	/**
	 * Loads the RL module defined for a given file extension, if any
	 *
	 * @param {string} extension File extension
	 * @return {jQuery.Promise}
	 */
	MMVP.loadExtensionPlugins = function ( extension ) {
		var deferred = $.Deferred(),
			config = this.config.extensions();

		if ( !( extension in config ) || config[ extension ] === 'default' ) {
			return deferred.resolve();
		}

		mw.loader.using( config[ extension ], function () {
			deferred.resolve();
		} );

		return deferred;
	};

	mw.mmv.MultimediaViewer = MultimediaViewer;
}() );
