( function () {
	const api = new mw.Api();
	const util = require( 'mediawiki.util' );

	/**
	 * Show the edit summary.
	 *
	 * @private
	 * @param {jQuery} $formNode
	 * @param {Object} response
	 */
	function showEditSummary( $formNode, response ) {
		const $summaryPreview = $formNode.find( '.mw-summary-preview' ).empty();
		const parse = response.parse;

		if ( !parse || !parse.parsedsummary ) {
			return;
		}

		$summaryPreview.append(
			mw.message( 'summary-preview' ).parse(),
			' ',
			$( '<span>' ).addClass( 'comment' ).html( parenthesesWrap( parse.parsedsummary ) )
		);
	}

	/**
	 * Wrap a string in parentheses.
	 *
	 * @private
	 * @param {string} str
	 * @return {string}
	 */
	function parenthesesWrap( str ) {
		if ( str === '' ) {
			return str;
		}
		// There is no equivalent to rawParams
		return mw.message( 'parentheses' ).escaped()
			// Specify a function as the replacement,
			// so that "$" characters in str are not interpreted.
			.replace( '$1', () => str );
	}

	/**
	 * Show status indicators.
	 *
	 * @private
	 * @param {Array} indicators
	 */
	function showIndicators( indicators ) {
		// eslint-disable-next-line no-jquery/no-map-util
		indicators = $.map( indicators, ( indicator, name ) => $( '<div>' )
			.addClass( 'mw-indicator' )
			.attr( 'id', mw.util.escapeIdForAttribute( 'mw-indicator-' + name ) )
			.html( indicator )
			.get( 0 ) );
		if ( indicators.length ) {
			mw.hook( 'wikipage.indicators' ).fire( $( indicators ) );
		}

		// Add whitespace between the <div>s because
		// they get displayed with display: inline-block
		const newList = [];
		indicators.forEach( ( indicator ) => {
			newList.push( indicator, document.createTextNode( '\n' ) );
		} );

		$( '.mw-indicators' ).empty().append( newList );
	}

	/**
	 * Show the templates used.
	 *
	 * The formatting here repeats what is done in includes/TemplatesOnThisPageFormatter.php
	 *
	 * @private
	 * @param {Array} templates List of template titles.
	 */
	function showTemplates( templates ) {
		// The .templatesUsed div can be empty, if no templates are in use.
		// In that case, we have to create the required structure.
		const $parent = $( '.templatesUsed' );

		// Find or add the explanation text (the toggler for collapsing).
		let $explanation = $parent.find( '.mw-templatesUsedExplanation p' );
		if ( $explanation.length === 0 ) {
			$explanation = $( '<p>' );
			$parent.append( $( '<div>' )
				.addClass( 'mw-templatesUsedExplanation' )
				.append( $explanation ) );
		}

		// Find or add the list. The makeCollapsible() method is called on this
		// in resources/src/mediawiki.action/mediawiki.action.edit.collapsibleFooter.js
		let $list = $parent.find( 'ul' );
		if ( $list.length === 0 ) {
			$list = $( '<ul>' ).addClass( [ 'mw-editfooter-list', 'mw-collapsible', 'mw-made-collapsible' ] );
			$parent.append( $list );
		}

		if ( templates.length === 0 ) {
			$explanation.msg( 'templatesusedpreview', 0 );
			$list.empty();
			return;
		}

		// Fetch info about all templates, batched because API is limited to 50 at a time.
		$parent.addClass( 'mw-preview-loading-elements-loading' );
		const batchSize = 50;
		const requests = [];
		for ( let batch = 0; batch < templates.length; batch += batchSize ) {
			// Build a list of template names for this batch.
			const titles = templates
				.slice( batch, batch + batchSize )
				.map( ( template ) => template.title );
			requests.push( api.post( {
				action: 'query',
				format: 'json',
				formatversion: 2,
				titles: titles,
				prop: 'info',
				// @todo Do we need inlinkcontext here?
				inprop: 'linkclasses|protection',
				intestactions: 'edit'
			} ) );
		}
		$.when( ...requests ).done( function () {
			const templatesAllInfo = [];
			// For the first batch, empty the list in preparation for either adding new items or not needing to.
			for ( let r = 0; r < arguments.length; r++ ) {
				// Response is either the whole argument, or the 0th element of it.
				const response = arguments[ r ][ 0 ] || arguments[ r ];
				const templatesInfo = ( response.query && response.query.pages ) || [];
				templatesInfo.forEach( ( ti ) => {
					templatesAllInfo.push( {
						title: mw.Title.newFromText( ti.title ),
						apiData: ti
					} );
				} );
			}
			// Sort alphabetically.
			templatesAllInfo.sort( ( t1, t2 ) => {
				// Compare titles with the same rules of Title::compare() in PHP.
				if ( t1.title.getNamespaceId() !== t2.title.getNamespaceId() ) {
					return t1.title.getNamespaceId() - t2.title.getNamespaceId();
				} else {
					return t1.title.getMain() === t2.title.getMain() ?
						0 :
						t1.title.getMain() < t2.title.getMain() ? -1 : 1;
				}
			} );

			// Add new template list, and update the list header.
			const $listNew = $( '<ul>' );
			addItemToTemplateListPromise( $listNew, templatesAllInfo, 0 )
				.then( () => {
					$list.html( $listNew.html() );
				} );
			$explanation.msg( 'templatesusedpreview', templatesAllInfo.length );
		} ).always( () => {
			$parent.removeClass( 'mw-preview-loading-elements-loading' );
		} );
	}

	/**
	 * Recursive function to add a template link to the list of templates in use.
	 * This is useful because addItemToTemplateList() might need to make extra API requests to fetch
	 * messages, but we don't want to send parallel requests for these (because they're often the
	 * for the same messages).
	 *
	 * @private
	 * @param {jQuery} $list The `<ul>` to add the item to.
	 * @param {Object} templatesInfo All templates' info, sorted by namespace and title.
	 * @param {number} templateIndex The current item in templatesInfo (0-indexed).
	 * @return {jQuery.Promise}
	 */
	function addItemToTemplateListPromise( $list, templatesInfo, templateIndex ) {
		return addItemToTemplateList( $list, templatesInfo[ templateIndex ] ).then( () => {
			if ( templatesInfo[ templateIndex + 1 ] !== undefined ) {
				return addItemToTemplateListPromise( $list, templatesInfo, templateIndex + 1 );
			}
		} );
	}

	/**
	 * Create list item with relevant links for the given template, and add it to the $list.
	 *
	 * @private
	 * @param {jQuery} $list The `<ul>` to add the item to.
	 * @param {Object} template Template info with which to construct the `<li>`.
	 * @return {jQuery.Promise}
	 */
	function addItemToTemplateList( $list, template ) {
		const editable = template.apiData.ns >= 0;
		const canEdit = editable && template.apiData.actions.edit !== undefined;
		const linkClasses = template.apiData.linkclasses || [];
		if ( template.apiData.missing !== undefined && template.apiData.known === undefined ) {
			linkClasses.push( 'new' );
		}
		const $baseLink = $( '<a>' )
			// Additional CSS classes (e.g. link colors) used for links to this template.
			// The following classes might be used here:
			// * new
			// * mw-redirect
			// * any added by the GetLinkColours hook
			.addClass( linkClasses );
		const $link = $baseLink.clone()
			.attr( 'href', template.title.getUrl() )
			.text( template.title.getPrefixedText() );

		if ( editable ) {
			const $editLink = $baseLink.clone()
				.attr( 'href', template.title.getUrl( { action: 'edit' } ) )
				.append( mw.msg( canEdit ? 'editlink' : 'viewsourcelink' ) );

			const wordSep = mw.message( 'word-separator' ).escaped();
			return getRestrictionsText( template.apiData.protection || [] )
				.then( ( restrictionsList ) => {
					// restrictionsList is a comma-separated parentheses-wrapped localized list of restriction level names.
					const editLinkParens = parenthesesWrap( $editLink[ 0 ].outerHTML );
					const $li = $( '<li>' ).append( $link, wordSep, editLinkParens, wordSep, restrictionsList );
					$list.append( $li );
				} );
		} else {
			$list.append( $( '<li>' ).append( $link ) );
			return $.Deferred().resolve( '' );
		}
	}

	/**
	 * Get a localized string listing the restriction levels for a template.
	 *
	 * This should match the logic from TemplatesOnThisPageFormatter::getRestrictionsText().
	 *
	 * @private
	 * @param {Array} restrictions Set of protection info objects from the inprop=protection API.
	 * @return {jQuery.Promise}
	 */
	function getRestrictionsText( restrictions ) {
		let msg = '';
		if ( !restrictions ) {
			return $.Deferred().resolve( msg );
		}

		// Record other restriction levels, in case it's protected for others.
		const restrictionLevels = [];
		restrictions.forEach( ( r ) => {
			if ( r.type !== 'edit' ) {
				return;
			}
			if ( r.level === 'sysop' ) {
				msg = mw.msg( 'template-protected' );
			} else if ( r.level === 'autoconfirmed' ) {
				msg = mw.msg( 'template-semiprotected' );
			} else {
				restrictionLevels.push( r.level );
			}
		} );

		// If sysop or autoconfirmed, use that.
		if ( msg !== '' ) {
			return $.Deferred().resolve( msg );
		}

		// Otherwise, if the edit restriction isn't one of the backwards-compatible ones,
		// use the (possibly custom) restriction-level-* messages.
		const msgs = [];
		restrictionLevels.forEach( ( level ) => {
			msgs.push( 'restriction-level-' + level );
		} );
		if ( msgs.length === 0 ) {
			return $.Deferred().resolve( '' );
		}

		// Custom restriction levels don't have their messages loaded, so we have to do that.
		return api.loadMessagesIfMissing( msgs ).then( () => {
			const localizedMessages = msgs.map(
				// Messages that can be used here include:
				// * restriction-level-sysop
				// * restriction-level-autoconfirmed
				( m ) => mw.message( m ).parse()
			);
			// There's no commaList in JS, so just join with commas (doesn't handle the last item).
			return parenthesesWrap( localizedMessages.join( mw.msg( 'comma-separator' ) ) );
		} );
	}

	/**
	 * Show the language links (Vector-specific).
	 * TODO: Doesn't work in vector-2022 (maybe it doesn't need to?)
	 *
	 * @private
	 * @param {Array} langLinks
	 */
	function showLanguageLinks( langLinks ) {
		const newList = langLinks.map( ( langLink ) => {
			const bcp47 = mw.language.bcp47( langLink.lang );
			// eslint-disable-next-line mediawiki/class-doc
			return $( '<li>' )
				.addClass( 'interlanguage-link interwiki-' + langLink.lang )
				.append( $( '<a>' )
					.attr( {
						href: langLink.url,
						title: langLink.title + ' - ' + langLink.langname,
						lang: bcp47,
						hreflang: bcp47
					} )
					.text( langLink.autonym )
				);
		} );
		const $list = $( '#p-lang ul' ),
			$parent = $list.parent();
		$list.detach().empty().append( newList ).prependTo( $parent );
	}

	/**
	 * Parse preview response and show a warning at the top of the preview.
	 *
	 * @private
	 * @param {Object} config
	 * @param {Object} response
	 */
	function showPreviewNotes( config, response ) {
		const arrow = $( document.body ).css( 'direction' ) === 'rtl' ? '←' : '→';
		const $previewHeader = $( '<div>' )
			.addClass( 'previewnote' )
			.append( $( '<h2>' )
				.attr( 'id', 'mw-previewheader' )
				// TemplateSandbox will insert an HTML string here.
				.append( config.previewHeader )
			);

		const warningContentElement = $( '<div>' )
			.append(
				// TemplateSandbox will insert a jQuery here.
				config.previewNote,
				' ',
				$( '<span>' )
					.addClass( 'mw-continue-editing' )
					.append( $( '<a>' )
						.attr( 'href', '#' + config.$formNode.attr( 'id' ) )
						.text( arrow + ' ' + mw.msg( 'continue-editing' ) )
					),
				response.parse.parsewarningshtml.map( ( warning ) => $( '<p>' ).append( warning ) )
			)[ 0 ];
		const warningMessageElement = util.messageBox(
			warningContentElement,
			'warning'
		);
		$previewHeader.append( warningMessageElement );
		config.$previewNode.prepend( $previewHeader );
	}

	/**
	 * Show an error message in place of a preview.
	 *
	 * @private
	 * @param {Object} config
	 * @param {jQuery} $message
	 */
	function showError( config, $message ) {
		const errorContentElement = $( '<div>' )
			.append(
				$( '<strong>' ).text( mw.msg( 'previewerrortext' ) ),
				$message
			)[ 0 ];
		const errorMessageElement = util.messageBox( errorContentElement, 'error' );
		errorMessageElement.classList.add( 'mw-page-preview-error' );
		config.$previewNode.hide().before( errorMessageElement );
		if ( config.$diffNode ) {
			config.$diffNode.hide();
		}
	}

	/**
	 * Update the various bits of the page based on the response.
	 *
	 * @private
	 * @param {Object} config
	 * @param {Object} response
	 */
	function handleParseResponse( config, response ) {
		let $content;

		// Js config variables and modules.
		if ( response.parse.jsconfigvars ) {
			mw.config.set( response.parse.jsconfigvars );
		}
		if ( response.parse.modules ) {
			mw.loader.load( response.parse.modules.concat(
				response.parse.modulestyles
			) );
		}

		// Indicators.
		showIndicators( response.parse.indicators );

		// Display title.
		if ( response.parse.displaytitle ) {
			$( '#firstHeadingTitle' ).html( response.parse.displaytitle );
		}

		// Categories.
		if ( response.parse.categorieshtml ) {
			$content = $( $.parseHTML( response.parse.categorieshtml ) );
			mw.hook( 'wikipage.categories' ).fire( $content );
			$( '.catlinks[data-mw="interface"]' ).replaceWith( $content );
		}

		// Table of contents.
		if ( response.parse.sections ) {
			/**
			 * Fired when dynamic changes have been made to the table of contents.
			 *
			 * @event ~'wikipage.tableOfContents'
			 * @memberof Hooks
			 * @param {Object[]} sections Metadata about each section, as returned by
			 *   [API:Parse]{@link https://www.mediawiki.org/wiki/Special:MyLanguage/API:Parsing_wikitext}.
			 */
			mw.hook( 'wikipage.tableOfContents' ).fire(
				response.parse.hidetoc ? [] : response.parse.sections
			);
		}

		// Templates.
		if ( response.parse.templates ) {
			showTemplates( response.parse.templates );
		}

		// Limit report.
		if ( response.parse.limitreporthtml ) {
			$( '.limitreport' ).html( response.parse.limitreporthtml )
				.find( '.mw-collapsible' ).makeCollapsible();
		}

		// Language links.
		if ( response.parse.langlinks && mw.config.get( 'skin' ) === 'vector' ) {
			showLanguageLinks( response.parse.langlinks );
		}

		if ( !response.parse.text ) {
			return;
		}

		// Remove any previous preview
		config.$previewNode.children( '.mw-parser-output' ).remove();
		// Remove preview note, if present (added by Live Preview, etc.).
		config.$previewNode.find( '.previewnote' ).remove();

		if ( config.isLivePreview ) {
			showPreviewNotes( config, response );
		}

		$content = $( $.parseHTML( response.parse.text ) );

		config.$previewNode.append( $content ).show();

		mw.hook( 'wikipage.content' ).fire( $content );
	}

	/**
	 * Get the unresolved promise of the preview request.
	 *
	 * @private
	 * @param {Object} config
	 * @param {string|number} section
	 * @return {jQuery.Promise}
	 */
	function getParseRequest( config, section ) {
		const params = {
			formatversion: 2,
			action: 'parse',
			summary: config.summary,
			prop: ''
		};
		params[ config.titleParam ] = config.title;

		if ( !config.showDiff ) {
			params[ config.textParam ] = config.$textareaNode.textSelection( 'getContents' );
			Object.assign( params, {
				prop: 'text|indicators|displaytitle|modules|jsconfigvars|categorieshtml|sections|templates|langlinks|limitreporthtml|parsewarningshtml',
				pst: true,
				preview: true,
				sectionpreview: section !== '',
				disableeditsection: true,
				useskin: mw.config.get( 'skin' ),
				uselang: mw.config.get( 'wgUserLanguage' )
			} );
			if ( mw.config.get( 'wgUserVariant' ) ) {
				params.variant = mw.config.get( 'wgUserVariant' );
			}
		}
		if ( section === 'new' ) {
			params.section = 'new';
			params.sectiontitle = params.summary;
			delete params.summary;
		}

		Object.assign( params, config.parseParams );

		return api.post( params, { headers: { 'Promise-Non-Write-API-Action': 'true' } } );
	}

	/**
	 * Get the required <table> structure for displaying diffs.
	 *
	 * @return {jQuery}
	 */
	function getDiffTable() {
		return $( '<table>' ).addClass( 'diff' ).append(
			$( '<col>' ).addClass( 'diff-marker' ),
			$( '<col>' ).addClass( 'diff-content' ),
			$( '<col>' ).addClass( 'diff-marker' ),
			$( '<col>' ).addClass( 'diff-content' ),
			$( '<thead>' ).append(
				$( '<tr>' ).addClass( 'diff-title' ).append(
					$( '<td>' )
						.attr( 'colspan', 2 )
						.addClass( 'diff-otitle diff-side-deleted' )
						.text( mw.msg( 'currentrev' ) ),
					$( '<td>' )
						.attr( 'colspan', 2 )
						.addClass( 'diff-ntitle diff-side-added' )
						.text( mw.msg( 'yourtext' ) )
				)
			),
			$( '<tbody>' )
		);
	}

	/**
	 * Show the diff from the response.
	 *
	 * @private
	 * @param {Object} config
	 * @param {Object} response
	 */
	function handleDiffResponse( config, response ) {
		const $table = getDiffTable();
		config.$diffNode
			.hide()
			.empty()
			.append( $table );

		const diff = response.compare.bodies;
		if ( diff.main ) {
			$table.find( 'tbody' ).html( diff.main );
			mw.hook( 'wikipage.diff' ).fire( $table );
		} else {
			// The diff is empty.
			const $tableCell = $( '<td>' )
				.attr( 'colspan', 4 )
				.addClass( 'diff-notice' )
				.append(
					$( '<div>' )
						.addClass( 'mw-diff-empty' )
						.text( mw.msg( 'diff-empty' ) )
				);
			$table.find( 'tbody' )
				.empty()
				.append(
					$( '<tr>' ).append( $tableCell )
				);
		}
		config.$diffNode.show();
	}

	/**
	 * Get the unresolved promise of the diff request.
	 *
	 * @private
	 * @param {Object} config
	 * @param {string|number} section
	 * @param {boolean} pageExists
	 * @return {jQuery.Promise}
	 */
	function getDiffRequest( config, section, pageExists ) {
		let contents = config.$textareaNode.textSelection( 'getContents' ),
			sectionTitle = config.summary;

		if ( section === 'new' ) {
			// T293930: Hack to show live diff for new section creation.

			// We concatenate the section heading with the edit box text and pass it to
			// the diff API as the full input text. This is roughly what the server-side
			// does when difference is requested for section edit.
			// The heading is always prepended, we do not bother with editing old rev
			// at this point (`?action=edit&oldid=xxx&section=new`) -- which will require
			// mid-text insertion of the section -- because creation of new section is only
			// possible on latest revision.

			// The section heading text is unconditionally wrapped in <h2> heading and
			// ends with double newlines, except when it's empty. This is for parity with the
			// server-side rendering of the same case.
			sectionTitle = sectionTitle === '' ? '' : '== ' + sectionTitle + ' ==\n\n';

			// Prepend section heading to section text.
			contents = sectionTitle + contents;
		}

		const params = {
			action: 'compare',
			fromtitle: config.title,
			totitle: config.title,
			toslots: 'main',
			// Remove trailing whitespace for consistency with EditPage diffs.
			// TODO trimEnd() when we can use that.
			'totext-main': contents.replace( /\s+$/, '' ),
			'tocontentmodel-main': mw.config.get( 'wgPageContentModel' ),
			topst: true,
			slots: 'main',
			uselang: mw.config.get( 'wgUserLanguage' )
		};
		if ( mw.config.get( 'wgUserVariant' ) ) {
			params.variant = mw.config.get( 'wgUserVariant' );
		}
		if ( section ) {
			params[ 'tosection-main' ] = section;
		}
		if ( !pageExists ) {
			params.fromslots = 'main';
			params[ 'fromcontentmodel-main' ] = mw.config.get( 'wgPageContentModel' );
			params[ 'fromtext-main' ] = '';
		}
		return api.post( params );
	}

	/**
	 * Get the selectors of elements that should be grayed out while the preview is being generated.
	 *
	 * @memberof module:mediawiki.page.preview
	 * @return {string[]}
	 * @stable
	 */
	function getLoadingSelectors() {
		return [
			// Main
			'.mw-indicators',
			'#firstHeading',
			'#wikiPreview',
			'#wikiDiff',
			'#catlinks',
			'#p-lang',
			// Editing-related
			'.templatesUsed',
			'.limitreport',
			'.mw-summary-preview',
			'.hiddencats'
		];
	}

	/**
	 * Fetch and display a preview of the current editing area.
	 *
	 * @memberof module:mediawiki.page.preview
	 * @param {Object} config Configuration options.
	 * @param {jQuery} [config.$previewNode=$( '#wikiPreview' )] Where the preview should be displayed.
	 * @param {jQuery} [config.$diffNode=$( '#wikiDiff' )] Where diffs should be displayed (if showDiff is set).
	 * @param {jQuery} [config.$formNode=$( '#editform' )] The form node.
	 * @param {jQuery} [config.$textareaNode=$( '#wpTextbox1' )] The edit form's textarea.
	 * @param {jQuery} [config.$spinnerNode=$( '.mw-spinner-preview' )] The loading indicator. This will
	 *   be shown/hidden accordingly while waiting for the XMLHttpRequest to complete.
	 *   Ignored if it doesn't exist in the document and `createSpinner` is false.
	 * @param {string} [config.summary=null] The edit summary. If no value is given, the summary is
	 *   fetched from `$( '#wpSummaryWidget' )`.
	 * @param {boolean} [config.showDiff=false] Shows a diff in the preview area instead of the content.
	 * @param {boolean} [config.isLivePreview=false] Instructs the module to replicate the
	 *   server-side preview as much as possible. Specifically:
	 *   - Before initiating the preview, some alerts and error messages at the top of the page will
	 *     be removed, and the browser will scroll to the preview.
	 *   - After finishing the preview, a reminder that it's only a preview, or an error message in
	 *     case a request has failed, will be shown at the top of the preview.
	 * @param {Node|Node[]|jQuery|string} [config.previewHeader=null] Content of `<h2>` element at
	 *   the top of the preview notes. Required if `isLivePreview` is true.
	 * @param {Node|Node[]|jQuery|string} [config.previewNote=null] Main text of the first preview
	 *   note. Required if `isLivePreview` is true.
	 * @param {string} [config.title=mw.config.get( 'wgPageName' )] The title of the page being previewed.
	 * @param {string} [config.titleParam='title'] Name of the parse API parameter to pass `title` to.
	 * @param {string} [config.textParam='text'] Name of the parse API parameter to pass the content
	 *   of `$textareaNode` to. Ignored if `showDiff` is true.
	 * @param {Object} [config.parseParams=null] Additional parse API parameters. This can override
	 *   any parameter set by the module.
	 * @param {module:mediawiki.page.preview~responseHandler} [config.responseHandler=null] Callback
	 *   to run right after the API responses are received. This allows the config and response
	 *   objects to be modified before the preview is shown.
	 * @param {boolean} [config.createSpinner=false] Creates `$spinnerNode` and inserts it before
	 *   `$previewNode` if one doesn't already exist and the module `jquery.spinner` is loaded.
	 * @param {string[]} [config.loadingSelectors=getLoadingSelectors()] An array of query selectors
	 *   (i.e. '#catlinks') that should be grayed out while the preview is being generated.
	 * @return {jQuery.Promise|undefined} jQuery.Promise or `undefined` if no `$textareaNode` was provided in the config.
	 * @fires Hooks~'wikipage.categories'
	 * @fires Hooks~'wikipage.content'
	 * @fires Hooks~'wikipage.diff'
	 * @fires Hooks~'wikipage.indicators'
	 * @fires Hooks~'wikipage.tableOfContents'
	 * @stable
	 */
	function doPreview( config ) {
		config = Object.assign( {
			$previewNode: $( '#wikiPreview' ),
			$diffNode: $( '#wikiDiff' ),
			$formNode: $( '#editform' ),
			$textareaNode: $( '#wpTextbox1' ),
			$spinnerNode: $( '.mw-spinner-preview' ),
			summary: null,
			showDiff: false,
			isLivePreview: false,
			previewHeader: null,
			previewNote: null,
			title: mw.config.get( 'wgPageName' ),
			titleParam: 'title',
			textParam: 'text',
			parseParams: null,
			responseHandler: null,
			createSpinner: false,
			loadingSelectors: getLoadingSelectors()
		}, config );

		const section = config.$formNode.find( '[name="wpSection"]' ).val();

		if ( !config.$textareaNode || config.$textareaNode.length === 0 ) {
			return;
		}

		// Fetch edit summary, if not already given.
		if ( !config.summary ) {
			const $summaryWidget = $( '#wpSummaryWidget' );
			if ( $summaryWidget.length ) {
				config.summary = OO.ui.infuse( $summaryWidget ).getValue();
			}
		}

		if ( config.isLivePreview ) {
			// Not shown during normal preview, to be removed if present
			$( '.mw-newarticletext, .mw-page-preview-error' ).remove();

			// Show #wikiPreview if it's hidden to be able to scroll to it.
			// (If it is hidden, it's also empty, so nothing changes in the rendering.)
			config.$previewNode.show();

			// Jump to where the preview will appear
			config.$previewNode[ 0 ].scrollIntoView();
		}

		// Show or create the spinner if possible.
		if ( config.$spinnerNode && config.$spinnerNode.length ) {
			config.$spinnerNode.show();
		} else if ( config.createSpinner ) {
			if ( mw.loader.getState( 'jquery.spinner' ) === 'ready' ) {
				config.$spinnerNode = $.createSpinner( {
					size: 'large',
					type: 'block'
				} )
					.addClass( 'mw-spinner-preview' )
					.insertBefore( config.$previewNode );
			} else {
				mw.log.warn( 'createSpinner requires the module jquery.spinner' );
			}
		}

		// Gray out the 'copy elements' while we wait for a response.
		const $loadingElements = $( config.loadingSelectors.join( ',' ) );
		$loadingElements.addClass( [ 'mw-preview-loading-elements', 'mw-preview-loading-elements-loading' ] );

		// Acquire a temporary user username before previewing or diffing, so that signatures and
		// user-related magic words display the temp user instead of IP user in the preview. (T331397)
		const tempUserNamePromise = mw.user.acquireTempUserName();

		let diffRequest;

		const parseRequest = tempUserNamePromise.then( () => getParseRequest( config, section ) );

		if ( config.showDiff ) {
			config.$previewNode.hide();

			// Add the diff node if it doesn't exist (directly after the preview node).
			if ( config.$diffNode.length === 0 && config.$previewNode.length > 0 ) {
				const rtlDir = $( '#wpTextbox1' ).attr( 'dir' ) === 'rtl';
				const alignStart = rtlDir ? 'right' : 'left';
				config.$diffNode = $( '<div>' )
					.attr( 'id', 'wikiDiff' )
					// The following classes are used here:
					// * diff-editfont-monospace
					// * diff-editfont-sans-serif
					// * diff-editfont-serif
					.addClass( 'diff-editfont-' + mw.user.options.get( 'editfont' ) )
					// The following classes are used here:
					// * diff-contentalign-left
					// * diff-contentalign-right
					.addClass( 'diff-contentalign-' + alignStart );
				config.$previewNode.after( config.$diffNode );
			}

			// Hide the table of contents, in case it was previously shown after previewing.
			mw.hook( 'wikipage.tableOfContents' ).fire( [] );

			// The compare API returns an error if the title doesn't exist and fromtext is not
			// specified. So we have to account for the possibility that the page was created or
			// deleted after the user started editing. Luckily the parse API returns pageid so we
			// can wait for that.
			// TODO: Show "Warning: This page was deleted after you started editing!"?
			diffRequest = parseRequest.then( ( parseResponse ) => getDiffRequest( config, section, parseResponse.parse.pageid !== 0 ) );

		} else if ( config.$diffNode ) {
			config.$diffNode.hide();
		}

		return $.when( parseRequest, diffRequest )
			.done( ( parseResponse, diffResponse ) => {
				if ( config.responseHandler ) {
					/**
					 * @callback module:mediawiki.page.preview~responseHandler
					 * @param {Object} config Options for live preview API
					 * @param {Object} parseResponse Parse API response
					 * @param {Object} [diffResponse] Compare API response
					 */
					if ( config.showDiff ) {
						config.responseHandler( config, parseResponse[ 0 ], diffResponse[ 0 ] );
					} else {
						config.responseHandler( config, parseResponse[ 0 ] );
					}
				}

				showEditSummary( config.$formNode, parseResponse[ 0 ] );

				if ( config.showDiff ) {
					handleDiffResponse( config, diffResponse[ 0 ] );
				} else {
					handleParseResponse( config, parseResponse[ 0 ] );
				}

				mw.hook( 'wikipage.editform' ).fire( config.$formNode );
			} )
			.fail( ( _code, result ) => {
				if ( config.isLivePreview ) {
					// This just shows the error for whatever request failed first
					showError( config, api.getErrorMessage( result ) );
				}
			} )
			.always( () => {
				if ( config.$spinnerNode && config.$spinnerNode.length ) {
					config.$spinnerNode.hide();
				}
				$loadingElements.removeClass( 'mw-preview-loading-elements-loading' );
			} );
	}

	/**
	 * Fetch and display a preview of the current editing area.
	 *
	 * @example
	 * var preview = require( 'mediawiki.page.preview' );
	 * preview.doPreview();
	 *
	 * @exports mediawiki.page.preview
	 */
	module.exports = {
		doPreview: doPreview,
		getLoadingSelectors: getLoadingSelectors
	};

}() );
