/*!
 * VisualEditor MediaWiki ArticleTargetSaver.
 *
 * @copyright See AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */

/**
 * Target saver.
 *
 * Light-weight saver.
 *
 * @class mw.libs.ve.targetSaver
 * @singleton
 * @hideconstructor
 */
( function () {
	mw.libs.ve = mw.libs.ve || {};

	mw.libs.ve.targetSaver = {
		/**
		 * Preload the library required for deflating so the user doesn't
		 * have to wait when postHtml is called.
		 */
		preloadDeflate: function () {
			mw.loader.load( 'mediawiki.deflate' );
		},

		/**
		 * Compress a string with deflate.
		 *
		 * @param {string} html HTML to deflate
		 * @return {jQuery.Promise} Promise resolved with deflated HTML
		 */
		deflate: function ( html ) {
			return mw.loader.using( 'mediawiki.deflate' ).then( () => mw.deflate( html ) );

		},

		/**
		 * Get HTML to send to Parsoid.
		 *
		 * If the document was generated from scratch (e.g. inside VisualEditor's converter), the
		 * source document can be passed in to transplant the head tag, as well as the attributes
		 * on the html and body tags.
		 *
		 * @param {HTMLDocument} newDoc Document generated by ve.dm.Converter. Will be modified.
		 * @param {HTMLDocument} [oldDoc] Old document to copy attributes from.
		 * @return {string} Full HTML document
		 */
		getHtml: function ( newDoc, oldDoc ) {
			function copyAttributes( from, to ) {
				Array.prototype.forEach.call( from.attributes, ( attr ) => {
					to.setAttribute( attr.name, attr.value );
				} );
			}

			if ( oldDoc ) {
				// Copy the head from the old document
				for ( let i = 0, len = oldDoc.head.childNodes.length; i < len; i++ ) {
					newDoc.head.appendChild( oldDoc.head.childNodes[ i ].cloneNode( true ) );
				}
				// Copy attributes from the old document for the html, head and body
				copyAttributes( oldDoc.documentElement, newDoc.documentElement );
				copyAttributes( oldDoc.head, newDoc.head );
				copyAttributes( oldDoc.body, newDoc.body );
			}

			// Filter out junk that may have been added by browser plugins
			$( newDoc )
				.find( [
					'script', // T54884, T65229, T96533, T103430
					'noscript', // T144891
					'object', // T65229
					'style:not( [ data-mw ] ):not( [ data-mw-deduplicate ] )', // T55252, but allow <style data-mw(-deduplicate)/> e.g. TemplateStyles T188143
					'embed', // T53521, T54791, T65121
					'a[href^="javascript:"]', // T200971
					'img[src^="data:"]', // T192392
					'div[id="myEventWatcherDiv"]', // T53423
					'div[id="sendToInstapaperResults"]', // T63776
					'div[id="kloutify"]', // T69006
					'div[id^="mittoHidden"]', // T70900
					'div.hon.certificateLink', // HON (T209619)
					'div.donut-container', // Web of Trust (T189148)
					'div.shield-container' // Web of Trust (T297862)
				].join( ',' ) )
				.each( ( j, el ) => {
					function truncate( text, l ) {
						return text.length > l ? text.slice( 0, l ) + '…' : text;
					}
					const errorMessage = 'DOM content matching deny list found:\n' + truncate( el.outerHTML, 100 ) +
						'\nContext:\n' + truncate( el.parentNode.outerHTML, 200 );
					mw.log.error( errorMessage );
					const err = new Error( errorMessage );
					err.name = 'VeDomDenyListWarning';
					mw.errorLogger.logError( err, 'error.visualeditor' );
					$( el ).remove();
				} );

			// data-mw-section-id is copied to headings by mw.libs.ve.unwrapParsoidSections
			// Remove these to avoid triggering selser.
			$( newDoc ).find( '[data-mw-section-id]:not( section )' ).removeAttr( 'data-mw-section-id' );

			// Deduplicate styles (we re-duplicated them in ve.init.mw.Target.static.parseDocument)
			// to let selser recognize the nodes and avoid dirty diffs.
			mw.libs.ve.deduplicateStyles( newDoc.body );

			// Add doctype manually
			// ve.properOuterHtml is loaded separately in ve.utils.parsing.js
			// eslint-disable-next-line no-undef
			return '<!doctype html>' + ve.properOuterHtml( newDoc.documentElement );
		},

		/**
		 * Serialize and deflate an HTML document
		 *
		 * @param {HTMLDocument} doc Document generated by ve.dm.Converter. Will be modified.
		 * @param {HTMLDocument} [oldDoc] Old document to copy attributes from.
		 * @return {jQuery.Promise} Promise resolved with deflated HTML
		 */
		deflateDoc: function ( doc, oldDoc ) {
			return this.deflate( this.getHtml( doc, oldDoc ) );
		},

		/**
		 * Post an HTML document to the API.
		 *
		 * Serializes the document to HTML, deflates it, then passes to #postHtml.
		 *
		 * @param {HTMLDocument} doc Document to save
		 * @param {Object} [extraData] Extra data to send to the API
		 * @param {Object} [options]
		 * @return {jQuery.Promise} Promise which resolves if the post was successful
		 */
		saveDoc: function ( doc, extraData, options ) {
			return this.deflateDoc( doc ).then( ( html ) => this.postHtml(
				html,
				null,
				extraData,
				options
			) );
		},

		/**
		 * Post wikitext to the API.
		 *
		 * By default uses action=visualeditoredit, paction=save.
		 *
		 * @param {string} wikitext Wikitext to post. Deflating is optional but recommended.
		 * @param {Object} [extraData] Extra data to send to the API
		 * @param {Object} [options]
		 * @param {mw.Api} [options.api] Api to use
		 * @param {Function} [options.now] Function returning current time in milliseconds for tracking, e.g. ve.now
		 * @param {Function} [options.track] Tracking function
		 * @param {string} [options.eventName] Event name for tracking
		 * @return {jQuery.Promise} Promise which resolves with API save data, or rejects with error details
		 */
		postWikitext: function ( wikitext, extraData, options ) {
			return this.postContent( $.extend( { wikitext: wikitext }, extraData ), options );
		},

		/**
		 * Post HTML to the API.
		 *
		 * By default uses action=visualeditoredit, paction=save.
		 *
		 * @param {string} html HTML to post. Deflating is optional but recommended.
		 *  Should be included for retries even if a cache key is provided.
		 * @param {string} [cacheKey] Optional cache key of HTML stashed on server.
		 * @param {Object} [extraData] Extra data to send to the API
		 * @param {Object} [options]
		 * @return {jQuery.Promise} Promise which resolves with API save data, or rejects with error details
		 */
		postHtml: function ( html, cacheKey, extraData, options ) {
			options = options || {};
			let data;
			if ( cacheKey ) {
				data = $.extend( { cachekey: cacheKey }, extraData );
			} else {
				data = $.extend( { html: html }, extraData );
			}
			return this.postContent( data, options ).then(
				null,
				( code, response ) => {
					// This cache key is evidently bad, clear it
					if ( options.onCacheKeyFail ) {
						options.onCacheKeyFail();
					}
					if ( code === 'badcachekey' ) {
						// If the cache key failed, try again without the cache key
						return this.postHtml(
							html,
							null,
							extraData,
							options
						);
					}
					// Failed for some other reason - let caller handle it.
					return $.Deferred().reject( code, response ).promise();
				}
			);
		},

		/**
		 * Post content to the API, using mw.Api#postWithToken to retry automatically when encountering
		 * a 'badtoken' error.
		 *
		 * By default uses action=visualeditoredit, paction=save.
		 *
		 * @param {string} data Content data
		 * @param {Object} [options]
		 * @param {mw.Api} [options.api] Api to use
		 * @param {Function} [options.now] Function returning current time in milliseconds for tracking, e.g. ve.now
		 * @param {Function} [options.track] Tracking function
		 * @param {string} [options.eventName] Event name for tracking
		 * @return {jQuery.Promise} Promise which resolves with API save data, or rejects with error details
		 */
		postContent: function ( data, options ) {
			options = options || {};
			const api = options.api || new mw.Api();

			let start;
			if ( options.now ) {
				start = options.now();
			}

			data = $.extend(
				{
					action: 'visualeditoredit',
					paction: 'save',
					useskin: mw.config.get( 'skin' ),
					// Same as OO.ui.isMobile()
					mobileformat: !!mw.config.get( 'wgMFMode' ),
					formatversion: 2,
					errorformat: 'html',
					errorlang: mw.config.get( 'wgUserLanguage' ),
					errorsuselocal: true
				},
				data
			);

			const action = data.action;

			const request = api.postWithToken( 'csrf', data, {
				contentType: 'multipart/form-data',
				trackEditAttemptStepSessionId: true
			} );

			return request.then(
				( response, jqxhr ) => {
					const responseData = response[ action ];

					// Log data about the request if eventName was set
					if ( options.track && options.eventName ) {
						const eventData = {
							bytes: require( 'mediawiki.String' ).byteLength( jqxhr.responseText ),
							duration: options.now() - start
						};
						const fullEventName = 'performance.system.' + options.eventName +
							( responseData.cachekey ? '.withCacheKey' : '.withoutCacheKey' );
						options.track( fullEventName, eventData );
					}

					let error;
					if ( !responseData ) {
						error = {
							code: 'invalidresponse',
							html: mw.message( 'api-clientside-error-invalidresponse' ).parse()
						};
					} else if ( responseData.result !== 'success' ) {
						// This should only happen when saving an edit and getting a captcha from ConfirmEdit
						// extension (`data.result === 'error'`). It's a silly special case...
						return $.Deferred().reject( 'no-error-no-success', response ).promise();
					} else {
						// paction specific errors
						switch ( responseData.paction ) {
							case 'save':
							case 'serialize':
								if ( typeof responseData.content !== 'string' ) {
									error = {
										code: 'invalidcontent',
										html: mw.message( 'api-clientside-error-invalidresponse' ).parse()
									};
								}
								break;
							case 'diff':
								if ( typeof responseData.diff !== 'string' ) {
									error = {
										code: 'invalidcontent',
										html: mw.message( 'api-clientside-error-invalidresponse' ).parse()
									};
								}
								break;
						}
					}

					if ( error ) {
						// Use the same format as API errors
						return $.Deferred().reject( error.code, { errors: [ error ] } ).promise();
					}
					return responseData;
				},
				( code, response ) => {
					const responseText = OO.getProp( response, 'xhr', 'responseText' );

					if ( responseText && options.track && options.eventName ) {
						const eventData = {
							bytes: require( 'mediawiki.String' ).byteLength( responseText ),
							duration: options.now() - start
						};
						let fullEventName;
						if ( code === 'badcachekey' ) {
							fullEventName = 'performance.system.' + options.eventName + '.badCacheKey';
						} else {
							fullEventName = 'performance.system.' + options.eventName + '.withoutCacheKey';
						}
						options.track( fullEventName, eventData );
					}
					return $.Deferred().reject( code, response ).promise();
				}
			);
		}
	};
}() );
