/*!
 * VisualEditor user interface MWGalleryDialog class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */

/**
 * Dialog for editing MediaWiki galleries.
 *
 * @class
 * @extends ve.ui.NodeDialog
 *
 * @constructor
 * @param {Object} [config] Configuration options
 */
ve.ui.MWGalleryDialog = function VeUiMWGalleryDialog() {
	// Parent constructor
	ve.ui.MWGalleryDialog.super.apply( this, arguments );

	this.$element.addClass( 've-ui-mwGalleryDialog' );
};

/* Inheritance */

OO.inheritClass( ve.ui.MWGalleryDialog, ve.ui.NodeDialog );

/* Static properties */

ve.ui.MWGalleryDialog.static.name = 'gallery';

ve.ui.MWGalleryDialog.static.size = 'large';

ve.ui.MWGalleryDialog.static.title =
	OO.ui.deferMsg( 'visualeditor-mwgallerydialog-title' );

ve.ui.MWGalleryDialog.static.modelClasses = [ ve.dm.MWGalleryNode ];

ve.ui.MWGalleryDialog.static.includeCommands = null;

ve.ui.MWGalleryDialog.static.excludeCommands = [
	// No formatting
	'paragraph',
	'heading1',
	'heading2',
	'heading3',
	'heading4',
	'heading5',
	'heading6',
	'preformatted',
	'blockquote',
	// No block-level markup is allowed inside gallery caption (or gallery image captions)
	// No tables
	'insertTable',
	'deleteTable',
	'mergeCells',
	'tableCaption',
	'tableCellHeader',
	'tableCellData',
	// No structure
	'bullet',
	'bulletWrapOnce',
	'number',
	'numberWrapOnce',
	'indent',
	'outdent',
	// Nested galleries don't work either
	'gallery'
];

/**
 * Get the import rules for the surface widget in the dialog
 *
 * @see ve.dm.ElementLinearData#sanitize
 * @return {Object} Import rules
 */
ve.ui.MWGalleryDialog.static.getImportRules = function () {
	var rules = ve.copy( ve.init.target.constructor.static.importRules );
	return ve.extendObject(
		rules,
		{
			all: {
				blacklist: ve.extendObject(
					{
						// No block-level markup is allowed inside gallery caption (or gallery image captions).
						// No lists, no tables.
						list: true,
						listItem: true,
						definitionList: true,
						definitionListItem: true,
						table: true,
						tableCaption: true,
						tableSection: true,
						tableRow: true,
						tableCell: true,
						mwTable: true,
						mwTransclusionTableCell: true,
						// Nested galleries don't work either
						mwGallery: true
					},
					ve.getProp( rules, 'all', 'blacklist' )
				),
				// Headings are also possible, but discouraged
				conversions: ve.extendObject(
					{
						mwHeading: 'paragraph'
					},
					ve.getProp( rules, 'all', 'conversions' )
				)
			}
		}
	);
};

/* Methods */

/**
 * @inheritdoc
 */
ve.ui.MWGalleryDialog.prototype.initialize = function () {
	// Parent method
	ve.ui.MWGalleryDialog.super.prototype.initialize.call( this );

	// States
	this.highlightedItem = null;
	this.searchPanelVisible = false;
	this.selectedFilenames = {};
	this.initialImageData = [];
	this.originalMwDataNormalized = null;
	this.originalGalleryGroupItems = [];
	this.imageData = {};
	this.isMobile = OO.ui.isMobile();

	// Default settings
	this.defaults = mw.config.get( 'wgVisualEditorConfig' ).galleryOptions;

	// Images and options tab panels
	this.indexLayout = new OO.ui.IndexLayout();
	var imagesTabPanel = new OO.ui.TabPanelLayout( 'images', {
		label: ve.msg( 'visualeditor-mwgallerydialog-card-images' ),
		// Contains a menu layout which handles its own scrolling
		scrollable: false,
		padded: true
	} );
	var optionsTabPanel = new OO.ui.TabPanelLayout( 'options', {
		label: ve.msg( 'visualeditor-mwgallerydialog-card-options' ),
		padded: true
	} );

	// Images tab panel

	// General layout
	var imageListContentPanel = new OO.ui.PanelLayout( {
		padded: true,
		expanded: true,
		scrollable: true
	} );
	var imageListMenuPanel = new OO.ui.PanelLayout( {
		padded: true,
		expanded: true
	} );
	this.imageListMenuLayout = new OO.ui.MenuLayout( {
		menuPosition: this.isMobile ? 'after' : 'bottom',
		classes: [
			've-ui-mwGalleryDialog-imageListMenuLayout',
			this.isMobile ?
				've-ui-mwGalleryDialog-imageListMenuLayout-mobile' :
				've-ui-mwGalleryDialog-imageListMenuLayout-desktop'
		],
		contentPanel: imageListContentPanel,
		menuPanel: imageListMenuPanel
	} );
	this.editPanel = new OO.ui.PanelLayout( {
		padded: true,
		expanded: true,
		scrollable: true
	} );
	this.searchPanel = new OO.ui.PanelLayout( {
		padded: true,
		expanded: true,
		scrollable: true
	} ).toggle( false );
	this.editSearchStack = new OO.ui.StackLayout( {
		items: [ this.editPanel, this.searchPanel ]
	} );
	this.imageTabMenuLayout = new OO.ui.MenuLayout( {
		menuPosition: this.isMobile ? 'top' : 'before',
		classes: [
			've-ui-mwGalleryDialog-menuLayout',
			this.isMobile ?
				've-ui-mwGalleryDialog-menuLayout-mobile' :
				've-ui-mwGalleryDialog-menuLayout-desktop'
		],
		menuPanel: this.imageListMenuLayout,
		contentPanel: this.editSearchStack
	} );

	// Menu
	this.$emptyGalleryMessage = $( '<div>' )
		.addClass( 'oo-ui-element-hidden' )
		.text( ve.msg( 'visualeditor-mwgallerydialog-empty-gallery-message' ) );
	this.galleryGroup = new ve.ui.MWGalleryGroupWidget( {
		orientation: this.isMobile ? 'horizontal' : 'vertical'
	} );
	this.showSearchPanelButton = new OO.ui.ButtonWidget( {
		label: ve.msg( 'visualeditor-mwgallerydialog-search-button-label' ),
		invisibleLabel: !!this.isMobile,
		icon: 'add',
		framed: false,
		flags: [ 'progressive' ],
		classes: [ 've-ui-mwGalleryDialog-show-search-panel-button' ]
	} );

	// Edit panel
	this.filenameFieldset = new OO.ui.FieldsetLayout( {
		label: ve.msg( 'visualeditor-dialog-media-content-filename' ),
		icon: 'image'
	} );
	this.$highlightedImage = $( '<div>' )
		.addClass( 've-ui-mwGalleryDialog-highlighted-image' );
	this.filenameFieldset.$element.append( this.$highlightedImage );
	this.highlightedCaptionTarget = ve.init.target.createTargetWidget( {
		includeCommands: this.constructor.static.includeCommands,
		excludeCommands: this.constructor.static.excludeCommands,
		importRules: this.constructor.static.getImportRules(),
		multiline: false
	} );
	this.highlightedAltTextInput = new OO.ui.TextInputWidget( {
		placeholder: ve.msg( 'visualeditor-dialog-media-alttext-section' )
	} );
	this.altTextSameAsCaption = new OO.ui.CheckboxInputWidget();
	this.removeButton = new OO.ui.ButtonWidget( {
		label: ve.msg( 'visualeditor-mwgallerydialog-remove-button-label' ),
		icon: 'trash',
		flags: [ 'destructive' ],
		classes: [ 've-ui-mwGalleryDialog-remove-button' ]
	} );

	var highlightedCaptionField = new OO.ui.FieldLayout( this.highlightedCaptionTarget, {
		align: 'top'
	} );
	var highlightedCaptionFieldset = new OO.ui.FieldsetLayout( {
		label: ve.msg( 'visualeditor-dialog-media-content-section' )
	} );
	highlightedCaptionFieldset.addItems( [ highlightedCaptionField ] );

	var highlightedAltTextField = new OO.ui.FieldLayout( this.highlightedAltTextInput, {
		align: 'top'
	} );
	var altTextSameAsCaptionField = new OO.ui.FieldLayout( this.altTextSameAsCaption, {
		align: 'inline',
		label: ve.msg( 'visualeditor-dialog-media-alttext-checkbox' )
	} );
	var highlightedAltTextFieldset = new OO.ui.FieldsetLayout( {
		label: ve.msg( 'visualeditor-dialog-media-alttext-section' )
	} );
	highlightedAltTextFieldset.addItems( [
		highlightedAltTextField,
		altTextSameAsCaptionField
	] );

	// Search panel
	this.searchWidget = new mw.widgets.MediaSearchWidget( {
		rowHeight: this.isMobile ? 100 : 150
	} );

	// Options tab panel

	// Input widgets
	this.modeDropdown = new OO.ui.DropdownWidget( {
		menu: {
			items: [
				new OO.ui.MenuOptionWidget( {
					data: 'traditional',
					label: ve.msg( 'visualeditor-mwgallerydialog-mode-dropdown-label-traditional' )
				} ),
				new OO.ui.MenuOptionWidget( {
					data: 'nolines',
					label: ve.msg( 'visualeditor-mwgallerydialog-mode-dropdown-label-nolines' )
				} ),
				new OO.ui.MenuOptionWidget( {
					data: 'packed',
					label: ve.msg( 'visualeditor-mwgallerydialog-mode-dropdown-label-packed' )
				} ),
				new OO.ui.MenuOptionWidget( {
					data: 'packed-overlay',
					label: ve.msg( 'visualeditor-mwgallerydialog-mode-dropdown-label-packed-overlay' )
				} ),
				new OO.ui.MenuOptionWidget( {
					data: 'packed-hover',
					label: ve.msg( 'visualeditor-mwgallerydialog-mode-dropdown-label-packed-hover' )
				} ),
				new OO.ui.MenuOptionWidget( {
					data: 'slideshow',
					label: ve.msg( 'visualeditor-mwgallerydialog-mode-dropdown-label-slideshow' )
				} )
			]
		}
	} );
	this.captionTarget = ve.init.target.createTargetWidget( {
		includeCommands: this.constructor.static.includeCommands,
		excludeCommands: this.constructor.static.excludeCommands,
		importRules: this.constructor.static.getImportRules(),
		multiline: false
	} );
	this.widthsInput = new OO.ui.NumberInputWidget( {
		min: 0,
		showButtons: false,
		input: {
			placeholder: ve.msg( 'visualeditor-mwgallerydialog-widths-input-placeholder', this.defaults.imageWidth )
		}
	} );
	this.heightsInput = new OO.ui.NumberInputWidget( {
		min: 0,
		showButtons: false,
		input: {
			placeholder: ve.msg( 'visualeditor-mwgallerydialog-heights-input-placeholder', this.defaults.imageHeight )
		}
	} );
	this.perRowInput = new OO.ui.NumberInputWidget( {
		min: 0,
		showButtons: false
	} );
	this.showFilenameCheckbox = new OO.ui.CheckboxInputWidget( {
		value: 'yes'
	} );
	this.classesInput = new OO.ui.TextInputWidget( {
		placeholder: ve.msg( 'visualeditor-mwgallerydialog-classes-input-placeholder' )
	} );
	this.stylesInput = new OO.ui.TextInputWidget( {
		placeholder: ve.msg( 'visualeditor-mwgallerydialog-styles-input-placeholder' )
	} );

	// Field layouts
	var modeField = new OO.ui.FieldLayout( this.modeDropdown, {
		label: ve.msg( 'visualeditor-mwgallerydialog-mode-field-label' )
	} );
	var captionField = new OO.ui.FieldLayout( this.captionTarget, {
		label: ve.msg( 'visualeditor-mwgallerydialog-caption-field-label' ),
		align: this.isMobile ? 'top' : 'left'
	} );
	var widthsField = new OO.ui.FieldLayout( this.widthsInput, {
		label: ve.msg( 'visualeditor-mwgallerydialog-widths-field-label' )
	} );
	var heightsField = new OO.ui.FieldLayout( this.heightsInput, {
		label: ve.msg( 'visualeditor-mwgallerydialog-heights-field-label' )
	} );
	var perRowField = new OO.ui.FieldLayout( this.perRowInput, {
		label: ve.msg( 'visualeditor-mwgallerydialog-perrow-field-label' )
	} );
	var showFilenameField = new OO.ui.FieldLayout( this.showFilenameCheckbox, {
		label: ve.msg( 'visualeditor-mwgallerydialog-show-filename-field-label' )
	} );
	var classesField = new OO.ui.FieldLayout( this.classesInput, {
		label: ve.msg( 'visualeditor-mwgallerydialog-classes-field-label' )
	} );
	var stylesField = new OO.ui.FieldLayout( this.stylesInput, {
		label: ve.msg( 'visualeditor-mwgallerydialog-styles-field-label' )
	} );

	// Append everything
	imageListMenuPanel.$element.append(
		this.showSearchPanelButton.$element
	);
	imageListContentPanel.$element.append(
		this.$emptyGalleryMessage,
		this.galleryGroup.$element
	);
	this.editPanel.$element.append(
		this.filenameFieldset.$element,
		highlightedCaptionFieldset.$element,
		highlightedAltTextFieldset.$element,
		this.removeButton.$element
	);
	this.searchPanel.$element.append(
		this.searchWidget.$element
	);
	imagesTabPanel.$element.append(
		this.imageTabMenuLayout.$element
	);
	optionsTabPanel.$element.append(
		modeField.$element,
		captionField.$element,
		widthsField.$element,
		heightsField.$element,
		perRowField.$element,
		showFilenameField.$element,
		classesField.$element,
		stylesField.$element
	);
	this.indexLayout.addTabPanels( [
		imagesTabPanel,
		optionsTabPanel
	] );
	this.$body.append( this.indexLayout.$element );
};

/**
 * @inheritdoc
 */
ve.ui.MWGalleryDialog.prototype.getSetupProcess = function ( data ) {
	return ve.ui.MWGalleryDialog.super.prototype.getSetupProcess.call( this, data )
		.next( function () {
			var namespaceIds = mw.config.get( 'wgNamespaceIds' ),
				dialog = this,
				mwData = this.selectedNode && this.selectedNode.getAttribute( 'mw' ),
				attributes = mwData && mwData.attrs,
				captionNode = this.selectedNode && this.selectedNode.getCaptionNode(),
				imageNodes = this.selectedNode && this.selectedNode.getImageNodes(),
				isReadOnly = this.isReadOnly();

			this.anyItemModified = false;

			// Images tab panel
			// If editing an existing gallery, populate with the images...
			if ( this.selectedNode ) {
				var imageTitles = [];

				for ( var i = 0, ilen = imageNodes.length; i < ilen; i++ ) {
					var image = imageNodes[ i ];
					var resourceTitle = mw.Title.newFromText( mw.libs.ve.normalizeParsoidResourceName( image.getAttribute( 'resource' ) ), namespaceIds.file );
					if ( !resourceTitle ) {
						continue;
					}
					var resource = resourceTitle.getPrefixedText();
					var imageCaptionNode = image.getCaptionNode();
					imageTitles.push( resource );
					this.initialImageData.push( {
						resource: resource,
						altText: image.getAttribute( 'altText' ),
						altTextSame: image.getAttribute( 'altTextSame' ),
						src: image.getAttribute( 'src' ),
						height: image.getAttribute( 'height' ),
						width: image.getAttribute( 'width' ),
						captionDocument: this.createCaptionDocument( imageCaptionNode ),
						tagName: image.getAttribute( 'tagName' ),
						isError: image.getAttribute( 'isError' ),
						errorText: image.getAttribute( 'errorText' )
					} );
				}

				// Populate menu and edit panels
				this.imagesPromise = this.requestImages( {
					titles: imageTitles
				} ).done( function () {
					dialog.onHighlightItem();
				} );

			// ...Otherwise show the search panel
			} else {
				this.toggleEmptyGalleryMessage( true );
				this.toggleSearchPanel( true );
			}

			// Options tab panel

			// Set options
			var mode = attributes && attributes.mode || this.defaults.mode;
			var widths = attributes && parseInt( attributes.widths ) || '';
			var heights = attributes && parseInt( attributes.heights ) || '';
			var perRow = attributes && attributes.perrow || '';
			var showFilename = attributes && attributes.showfilename === 'yes';
			var classes = attributes && attributes.class || '';
			var styles = attributes && attributes.style || '';
			// Caption
			this.captionDocument = this.createCaptionDocument( captionNode );

			// Populate options panel
			this.modeDropdown.getMenu().selectItemByData( mode );
			this.widthsInput.setValue( widths );
			this.heightsInput.setValue( heights );
			this.perRowInput.setValue( perRow );
			this.showFilenameCheckbox.setSelected( showFilename );
			this.classesInput.setValue( classes );
			this.stylesInput.setValue( styles );
			// Caption
			this.captionTarget.setDocument( this.captionDocument );
			this.captionTarget.setReadOnly( isReadOnly );

			if ( mwData ) {
				this.originalMwDataNormalized = ve.copy( mwData );
				this.updateMwData( this.originalMwDataNormalized );
			}

			this.highlightedAltTextInput.setReadOnly( isReadOnly || this.altTextSameAsCaption.isSelected() );
			this.altTextSameAsCaption.setDisabled( isReadOnly );
			this.modeDropdown.setDisabled( isReadOnly );
			this.widthsInput.setReadOnly( isReadOnly );
			this.heightsInput.setReadOnly( isReadOnly );
			this.perRowInput.setReadOnly( isReadOnly );
			this.showFilenameCheckbox.setDisabled( isReadOnly );
			this.classesInput.setReadOnly( isReadOnly );
			this.stylesInput.setReadOnly( isReadOnly );

			this.showSearchPanelButton.setDisabled( isReadOnly );
			this.removeButton.setDisabled( isReadOnly );

			this.galleryGroup.toggleDraggable( !isReadOnly );

			// Disable fields depending on mode
			this.onModeDropdownChange();

			// Add event handlers
			this.indexLayout.connect( this, { set: 'updateDialogSize' } );
			this.searchWidget.getResults().connect( this, { choose: 'onSearchResultsChoose' } );
			this.showSearchPanelButton.connect( this, { click: 'onShowSearchPanelButtonClick' } );
			this.galleryGroup.connect( this, { editItem: 'onHighlightItem' } );
			this.galleryGroup.connect( this, { change: 'updateActions' } );
			this.removeButton.connect( this, { click: 'onRemoveItem' } );
			this.modeDropdown.getMenu().connect( this, { choose: 'onModeDropdownChange' } );
			this.widthsInput.connect( this, { change: 'updateActions' } );
			this.heightsInput.connect( this, { change: 'updateActions' } );
			this.perRowInput.connect( this, { change: 'updateActions' } );
			this.showFilenameCheckbox.connect( this, { change: 'updateActions' } );
			this.classesInput.connect( this, { change: 'updateActions' } );
			this.stylesInput.connect( this, { change: 'updateActions' } );
			this.captionTarget.connect( this, { change: 'updateActions' } );
			this.highlightedAltTextInput.connect( this, { change: 'updateActions' } );
			this.altTextSameAsCaption.connect( this, { change: 'onAltTextSameAsCaptionChange' } );
			this.highlightedCaptionTarget.connect( this, { change: 'updateActions' } );

			return this.imagesPromise;
		}, this );
};

/**
 * Get a new caption document for the gallery caption or an image caption.
 *
 * @private
 * @param {ve.dm.MWGalleryCaptionNode|ve.dm.MWGalleryImageCaptionNode|null} captionNode
 * @return {ve.dm.Document}
 */
ve.ui.MWGalleryDialog.prototype.createCaptionDocument = function ( captionNode ) {
	if ( captionNode && captionNode.getLength() > 0 ) {
		return this.selectedNode.getDocument().cloneFromRange( captionNode.getRange() );
	} else {
		return this.getFragment().getDocument().cloneWithData( [
			{ type: 'paragraph', internal: { generated: 'wrapper' } },
			{ type: '/paragraph' },
			{ type: 'internalList' },
			{ type: '/internalList' }
		] );
	}
};

/**
 * @inheritdoc
 */
ve.ui.MWGalleryDialog.prototype.getReadyProcess = function ( data ) {
	return ve.ui.MWGalleryDialog.super.prototype.getReadyProcess.call( this, data )
		.next( function () {
			this.searchWidget.getQuery().focus().select();
		}, this );
};

/**
 * @inheritdoc
 */
ve.ui.MWGalleryDialog.prototype.getTeardownProcess = function ( data ) {
	return ve.ui.MWGalleryDialog.super.prototype.getTeardownProcess.call( this, data )
		.first( function () {
			// Layouts
			this.indexLayout.setTabPanel( 'images' );
			this.indexLayout.resetScroll();
			this.imageTabMenuLayout.resetScroll();

			// Widgets
			this.galleryGroup.clearItems();
			this.searchWidget.getQuery().setValue( '' );
			this.searchWidget.teardown();

			// States
			this.highlightedItem = null;
			this.searchPanelVisible = false;
			this.selectedFilenames = {};
			this.initialImageData = [];
			this.originalMwDataNormalized = null;
			this.originalGalleryGroupItems = [];

			// Disconnect events
			this.indexLayout.disconnect( this );
			this.searchWidget.getResults().disconnect( this );
			this.showSearchPanelButton.disconnect( this );
			this.galleryGroup.disconnect( this );
			this.removeButton.disconnect( this );
			this.modeDropdown.disconnect( this );
			this.widthsInput.disconnect( this );
			this.heightsInput.disconnect( this );
			this.perRowInput.disconnect( this );
			this.showFilenameCheckbox.disconnect( this );
			this.classesInput.disconnect( this );
			this.stylesInput.disconnect( this );
			this.highlightedAltTextInput.disconnect( this );
			this.altTextSameAsCaption.disconnect( this );
			this.captionTarget.disconnect( this );
			this.highlightedCaptionTarget.disconnect( this );

		}, this );
};

ve.ui.MWGalleryDialog.prototype.getActionProcess = function ( action ) {
	return ve.ui.MWGalleryDialog.super.prototype.getActionProcess.call( this, action )
		.next( function () {
			if ( action === 'done' ) {
				// Save the input values for the highlighted item
				this.updateHighlightedItem();

				this.insertOrUpdateNode();
				this.close( { action: 'done' } );
			}
		}, this );
};

/**
 * @inheritdoc
 */
ve.ui.MWGalleryDialog.prototype.getBodyHeight = function () {
	return 600;
};

/**
 * Request the images for the images tab panel menu
 *
 * @param {Object} options Options for the request
 * @return {jQuery.Promise} Promise which resolves when image data has been fetched
 */
ve.ui.MWGalleryDialog.prototype.requestImages = function ( options ) {
	var dialog = this,
		promises = [];

	var i, len;
	for ( i = 0, len = options.titles.length; i < len; i++ ) {
		promises.push( ve.init.platform.galleryImageInfoCache.get( options.titles[ i ] ) );
	}
	return ve.promiseAll( promises )
		.done( function () {
			var resp = {};
			for ( i = 0; i < len; i++ ) {
				resp[ options.titles[ i ] ] = arguments[ i ];
			}
			dialog.onRequestImagesSuccess( resp );
		} );
};

/**
 * Create items for the returned images and add them to the gallery group
 *
 * @param {Object} response jQuery response object
 */
ve.ui.MWGalleryDialog.prototype.onRequestImagesSuccess = function ( response ) {
	var thumbUrls = {},
		items = [],
		config = { isMobile: this.isMobile, draggable: !this.isReadOnly() };

	var title;
	for ( title in response ) {
		thumbUrls[ title ] = {
			thumbUrl: response[ title ].thumburl,
			width: response[ title ].thumbwidth,
			height: response[ title ].thumbheight
		};
	}

	if ( this.initialImageData.length > 0 ) {
		this.initialImageData.forEach( function ( image ) {
			image.thumbUrl = thumbUrls[ image.resource ].thumbUrl;
			items.push( new ve.ui.MWGalleryItemWidget( image, config ) );
		} );
		this.initialImageData = [];
		this.originalGalleryGroupItems = ve.copy( items );
	} else {
		for ( title in this.selectedFilenames ) {
			if ( Object.prototype.hasOwnProperty.call( thumbUrls, title ) ) {
				items.push( new ve.ui.MWGalleryItemWidget( {
					resource: title,
					altText: null,
					altTextSame: true,
					src: '',
					height: thumbUrls[ title ].height,
					width: thumbUrls[ title ].width,
					thumbUrl: thumbUrls[ title ].thumbUrl,
					captionDocument: this.createCaptionDocument( null ),
					isError: false,
					errorText: null
				}, config ) );
				delete this.selectedFilenames[ title ];
			}
		}
	}

	this.galleryGroup.addItems( items );

	// Gallery is no longer empty
	this.updateActions();
	this.toggleEmptyGalleryMessage( false );
};

/**
 * Request a new image and highlight it
 *
 * @param {string} title Normalized title of the new image
 */
ve.ui.MWGalleryDialog.prototype.addNewImage = function ( title ) {
	var dialog = this;

	// Make list of unique pending images, for onRequestImagesSuccess
	this.selectedFilenames[ title ] = true;

	// Request image
	this.requestImages( {
		titles: [ title ]
	} ).done( function () {
		// populate edit panel with the new image
		var items = dialog.galleryGroup.items;
		dialog.onHighlightItem( items[ items.length - 1 ] );
		dialog.highlightedCaptionTarget.focus();
	} );
};

/**
 * Update the image currently being edited (ve.ui.MWGalleryItemWidget) with the values from inputs
 * in this dialog (currently only the image caption).
 */
ve.ui.MWGalleryDialog.prototype.updateHighlightedItem = function () {
	this.anyItemModified = this.anyItemModified || this.isHighlightedItemModified();

	// TODO: Support link, page and lang
	if ( this.highlightedItem ) {
		// No need to call setCaptionDocument(), the document object is updated on every change
		this.highlightedItem.setAltText( this.highlightedAltTextInput.getValue() );
		this.highlightedItem.setAltTextSame( this.altTextSameAsCaption.isSelected() );
	}
};

/**
 * Handle search results choose event.
 *
 * @param {mw.widgets.MediaResultWidget} item Chosen item
 */
ve.ui.MWGalleryDialog.prototype.onSearchResultsChoose = function ( item ) {
	var title = mw.Title.newFromText( item.getData().title ).getPrefixedText();

	// Check title against pending insertions
	// TODO: Prevent two 'choose' events firing from the UI
	if ( !Object.prototype.hasOwnProperty.call( this.selectedFilenames, title ) ) {
		this.addNewImage( title );
	}

	this.updateActions();
};

/**
 * Handle click event for the remove button
 */
ve.ui.MWGalleryDialog.prototype.onRemoveItem = function () {
	// Remove the highlighted item
	this.galleryGroup.removeItems( [ this.highlightedItem ] );

	// Highlight another item, or show the search panel if the gallery is now empty
	this.onHighlightItem();
};

/**
 * Handle clicking on an image in the menu
 *
 * @param {ve.ui.MWGalleryItemWidget} [item] The item that was clicked on
 */
ve.ui.MWGalleryDialog.prototype.onHighlightItem = function ( item ) {
	// Unhighlight previous item
	if ( this.highlightedItem ) {
		this.highlightedItem.toggleHighlighted( false );
	}

	// Show edit panel
	// (This also calls updateHighlightedItem() to save the input values.)
	this.toggleSearchPanel( false );

	// Highlight new item.
	// If no item was given, highlight the first item in the gallery.
	item = item || this.galleryGroup.items[ 0 ];

	if ( !item ) {
		// Show the search panel if the gallery is empty
		this.toggleEmptyGalleryMessage( true );
		this.toggleSearchPanel( true );
		return;
	}

	item.toggleHighlighted( true );
	this.highlightedItem = item;

	// Scroll item into view in menu
	OO.ui.Element.static.scrollIntoView( item.$element[ 0 ] );

	// Populate edit panel
	var title = mw.Title.newFromText( mw.libs.ve.normalizeParsoidResourceName( item.resource ) );
	var $link = $( '<a>' )
		.addClass( 've-ui-mwMediaDialog-description-link' )
		.attr( 'target', '_blank' )
		.attr( 'rel', 'noopener' )
		.text( ve.msg( 'visualeditor-dialog-media-content-description-link' ) );

	// T322704
	ve.setAttributeSafe( $link[ 0 ], 'href', title.getUrl(), '#' );

	this.filenameFieldset.setLabel(
		$( '<span>' ).append(
			$( document.createTextNode( title.getMainText() + ' ' ) ),
			$link
		)
	);
	this.$highlightedImage
		.css( 'background-image', 'url(' + item.thumbUrl + ')' );
	this.highlightedCaptionTarget.setDocument( item.captionDocument );
	this.highlightedCaptionTarget.setReadOnly( this.isReadOnly() );
	this.highlightedAltTextInput.setValue( item.altText );
	this.highlightedAltTextInput.setReadOnly( this.isReadOnly() || item.altTextSame );
	this.altTextSameAsCaption.setSelected( item.altTextSame );
};

/**
 * Handle change event for this.modeDropdown
 */
ve.ui.MWGalleryDialog.prototype.onModeDropdownChange = function () {
	var mode = this.modeDropdown.getMenu().findSelectedItem().getData(),
		disabled = (
			mode === 'packed' ||
			mode === 'packed-overlay' ||
			mode === 'packed-hover' ||
			mode === 'slideshow'
		);

	this.widthsInput.setDisabled( disabled );
	this.perRowInput.setDisabled( disabled );

	// heights is only ignored in slideshow mode
	this.heightsInput.setDisabled( mode === 'slideshow' );

	this.updateActions();
};

/**
 * Handle change event for this.altTextSameAsCaption
 */
ve.ui.MWGalleryDialog.prototype.onAltTextSameAsCaptionChange = function () {
	this.highlightedAltTextInput.setReadOnly( this.isReadOnly() || this.altTextSameAsCaption.isSelected() );
	this.updateActions();
};

/**
 * Handle click event for showSearchPanelButton
 */
ve.ui.MWGalleryDialog.prototype.onShowSearchPanelButtonClick = function () {
	this.toggleSearchPanel( true );
};

/**
 * Toggle the search panel (and the edit panel, the opposite way)
 *
 * @param {boolean} [visible] The search panel is visible
 */
ve.ui.MWGalleryDialog.prototype.toggleSearchPanel = function ( visible ) {
	visible = visible !== undefined ? visible : !this.searchPanelVisible;

	// If currently visible panel is an edit panel, save the input values for the highlighted item
	if ( !this.searchPanelVisible ) {
		this.updateHighlightedItem();
	}

	// Record the state of the search panel
	this.searchPanelVisible = visible;

	// Toggle the search panel, and do the opposite for the edit panel
	this.editSearchStack.setItem( visible ? this.searchPanel : this.editPanel );

	this.imageListMenuLayout.toggleMenu( !visible );
	if ( this.highlightedItem && visible ) {
		this.highlightedItem.toggleHighlighted( false );
		this.highlightedItem = null;
	}

	// If the edit panel is visible, focus the caption target
	if ( !visible ) {
		this.highlightedCaptionTarget.focus();
	} else {
		// Try to populate with user uploads
		this.searchWidget.queryMediaQueue();
		this.searchWidget.getQuery().focus().select();
	}
	this.updateDialogSize();
};

/**
 * Resize the dialog according to which panel is focused
 */
ve.ui.MWGalleryDialog.prototype.updateDialogSize = function () {
	if ( this.searchPanelVisible && this.indexLayout.currentTabPanelName === 'images' ) {
		this.setSize( 'larger' );
	} else {
		this.setSize( 'large' );
	}
};

/**
 * Toggle the empty gallery message
 *
 * @param {boolean} empty The gallery is empty
 */
ve.ui.MWGalleryDialog.prototype.toggleEmptyGalleryMessage = function ( empty ) {
	if ( empty ) {
		this.$emptyGalleryMessage.removeClass( 'oo-ui-element-hidden' );
	} else {
		this.$emptyGalleryMessage.addClass( 'oo-ui-element-hidden' );
	}
};

/**
 * Disable the "Done" button if the gallery is empty, otherwise enable it
 *
 * TODO Disable the button until the user makes any changes
 */
ve.ui.MWGalleryDialog.prototype.updateActions = function () {
	this.actions.setAbilities( { done: this.isModified() } );
};

/**
 * Check if gallery attributes or contents would be modified if changes were applied.
 *
 * @return {boolean}
 */
ve.ui.MWGalleryDialog.prototype.isModified = function () {
	// Check attributes
	if ( this.originalMwDataNormalized ) {
		var mwDataCopy = ve.copy( this.selectedNode.getAttribute( 'mw' ) );
		this.updateMwData( mwDataCopy );
		if ( !ve.compare( mwDataCopy, this.originalMwDataNormalized ) ) {
			return true;
		}
	}
	if ( this.captionTarget.hasBeenModified() ) {
		return true;
	}

	// Check contents: each image's attributes and contents (caption)
	if ( this.anyItemModified || this.isHighlightedItemModified() ) {
		return true;
	}

	// Check contents: added/removed/reordered images
	if ( this.originalGalleryGroupItems ) {
		if ( this.galleryGroup.items.length !== this.originalGalleryGroupItems.length ) {
			return true;
		}
		for ( var i = 0; i < this.galleryGroup.items.length; i++ ) {
			if ( this.galleryGroup.items[ i ] !== this.originalGalleryGroupItems[ i ] ) {
				return true;
			}
		}
	}

	return false;
};

/**
 * Check if currently highlighted item's attributes or contents would be modified if changes were
 * applied.
 *
 * @return {boolean}
 */
ve.ui.MWGalleryDialog.prototype.isHighlightedItemModified = function () {
	if ( this.highlightedItem ) {
		if ( this.highlightedAltTextInput.getValue() !== this.highlightedItem.altText ) {
			return true;
		}
		if ( this.altTextSameAsCaption.isSelected() !== this.highlightedItem.altTextSame ) {
			return true;
		}
		if ( this.highlightedCaptionTarget.hasBeenModified() ) {
			return true;
		}
	}
	return false;
};

/**
 * Insert or update the node in the document model from the new values
 */
ve.ui.MWGalleryDialog.prototype.insertOrUpdateNode = function () {
	var surfaceModel = this.getFragment().getSurface(),
		surfaceModelDocument = surfaceModel.getDocument(),
		items = this.galleryGroup.items,
		data = [];

	var mwData;

	function scaleImage( height, width, maxHeight, maxWidth ) {
		var heightScaleFactor = maxHeight / height;
		var widthScaleFactor = maxWidth / width;

		var scaleFactor = width * heightScaleFactor > maxWidth ? widthScaleFactor : heightScaleFactor;

		return {
			height: Math.round( height * scaleFactor ),
			width: Math.round( width * scaleFactor )
		};
	}

	/**
	 * Get linear data from a gallery item
	 *
	 * @param {ve.ui.MWGalleryItemWidget} galleryItem Gallery item
	 * @return {Array} Linear data
	 */
	function getImageLinearData( galleryItem ) {
		var size = scaleImage(
			parseInt( galleryItem.height ),
			parseInt( galleryItem.width ),
			parseInt( mwData.attrs.heights || this.defaults.imageHeight ),
			parseInt( mwData.attrs.widths || this.defaults.imageWidth )
		);
		var imageAttributes = {
			resource: './' + galleryItem.resource,
			altText: ( !galleryItem.altText && !galleryItem.originalAltText ) ?
				// Use original null/empty value
				galleryItem.originalAltText :
				galleryItem.altText,
			altTextSame: galleryItem.altTextSame,
			// For existing images use `src` to avoid triggering a diff if the
			// thumbnail size changes. For new images we have to use `thumbUrl` (T310623).
			src: galleryItem.src || galleryItem.thumbUrl,
			height: size.height,
			width: size.width,
			tagName: galleryItem.tagName,
			isError: galleryItem.isError,
			errorText: galleryItem.errorText
		};

		return [
			{ type: 'mwGalleryImage', attributes: imageAttributes },
			{ type: 'mwGalleryImageCaption' },
			// Actual caption contents are inserted later
			{ type: '/mwGalleryImageCaption' },
			{ type: '/mwGalleryImage' }
		];
	}

	var innerRange;
	if ( this.selectedNode ) {
		// Update mwData
		mwData = ve.copy( this.selectedNode.getAttribute( 'mw' ) );
		this.updateMwData( mwData );
		surfaceModel.change(
			ve.dm.TransactionBuilder.static.newFromAttributeChanges(
				surfaceModelDocument,
				this.selectedNode.getOuterRange().start,
				{ mw: mwData }
			)
		);

		innerRange = this.selectedNode.getRange();
	} else {
		// Make gallery node and mwData
		var element = {
			type: 'mwGallery',
			attributes: {
				mw: {
					name: 'gallery',
					attrs: {},
					body: {}
				}
			}
		};
		mwData = element.attributes.mw;
		this.updateMwData( mwData );
		// Collapse returns a new fragment, so update this.fragment
		this.fragment = this.getFragment().collapseToEnd();
		this.getFragment().insertContent( [
			element,
			{ type: '/mwGallery' }
		] );

		innerRange = new ve.Range( this.fragment.getSelection().getRange().from + 1 );
	}

	// Update all child elements' data, but without the contents of the captions
	if ( this.captionDocument.data.hasContent() ) {
		data = data.concat( [
			{ type: 'mwGalleryCaption' },
			{ type: '/mwGalleryCaption' }
		] );
	}
	var i, ilen;
	// Build node for each image
	for ( i = 0, ilen = items.length; i < ilen; i++ ) {
		data = data.concat( getImageLinearData.call( this, items[ i ] ) );
	}
	// Replace whole contents of this node with the new ones
	surfaceModel.change(
		ve.dm.TransactionBuilder.static.newFromReplacement(
			surfaceModelDocument,
			innerRange,
			data
		)
	);

	// Minus 2 to skip past </mwGalleryImageCaption></mwGalleryImage>
	var captionInsertionOffset = innerRange.from + data.length - 2;
	// Update image captions. In reverse order to avoid having to adjust offsets for each insertion.
	for ( i = items.length - 1; i >= 0; i-- ) {
		surfaceModel.change(
			ve.dm.TransactionBuilder.static.newFromDocumentInsertion(
				surfaceModel.getDocument(),
				captionInsertionOffset,
				items[ i ].captionDocument
			)
		);
		// Skip past </mwGalleryImageCaption></mwGalleryImage><mwGalleryImage><mwGalleryImageCaption>
		captionInsertionOffset -= 4;
	}

	// Update gallery caption
	if ( this.captionDocument.data.hasContent() ) {
		surfaceModel.change(
			ve.dm.TransactionBuilder.static.newFromDocumentInsertion(
				surfaceModel.getDocument(),
				// Plus 1 to skip past <mwGalleryCaption>
				innerRange.from + 1,
				this.captionDocument
			)
		);
	}
};

/**
 * Update the 'mw' attribute with data from inputs in the dialog.
 *
 * @param {Object} mwData Value of the 'mw' attribute, updated in-place
 * @private
 */
ve.ui.MWGalleryDialog.prototype.updateMwData = function ( mwData ) {
	// Need to do this, otherwise mwData.body.extsrc will override all attribute changes
	mwData.body = {};
	// Need to do this, otherwise it will override the caption from the gallery caption node
	delete mwData.attrs.caption;
	// Update attributes
	var mode;
	if ( this.modeDropdown.getMenu().findSelectedItem() ) {
		mode = this.modeDropdown.getMenu().findSelectedItem().getData();
	}
	// Unset mode attribute if it is the same as the default
	mwData.attrs.mode = mode === this.defaults.mode ? undefined : mode;
	mwData.attrs.widths = this.widthsInput.getValue() || undefined;
	mwData.attrs.heights = this.heightsInput.getValue() || undefined;
	mwData.attrs.perrow = this.perRowInput.getValue() || undefined;
	mwData.attrs.showfilename = this.showFilenameCheckbox.isSelected() ? 'yes' : undefined;
	mwData.attrs.class = this.classesInput.getValue() || undefined;
	mwData.attrs.style = this.stylesInput.getValue() || undefined;
};

/* Registration */

ve.ui.windowFactory.register( ve.ui.MWGalleryDialog );
