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

// TODO: ve.now and ve.track should be moved to mw.libs.ve
/* global ve */

/**
 * Target loader.
 *
 * Light-weight loader that loads ResourceLoader modules for VisualEditor
 * and HTML and page data from the API. Also handles plugin registration.
 *
 * @class mw.libs.ve.targetLoader
 * @singleton
 * @hideconstructor
 */
( function () {
	mw.libs.ve = mw.libs.ve || {};

	const conf = mw.config.get( 'wgVisualEditorConfig' ),
		pluginCallbacks = [],
		modules = [
			'ext.visualEditor.articleTarget',
			// Add modules from $wgVisualEditorPluginModules
			...conf.pluginModules.filter( mw.loader.getState )
		];

	const url = new URL( location.href );

	mw.libs.ve.initialUrl = url;

	// Provide the new wikitext editor
	if (
		mw.user.options.get( 'visualeditor-newwikitext' ) ||
		url.searchParams.get( 'veaction' ) === 'editsource'
	) {
		modules.push( 'ext.visualEditor.mwwikitext' );
	}

	// Collab
	if (
		mw.user.options.get( 'visualeditor-collab' ) ||
		url.searchParams.has( 'collabSession' )
	) {
		modules.push( 'ext.visualEditor.collab' );
	}

	// A/B test enrollment for edit check (T389231)
	// Note: this happens here rather than inside editcheck so that the bucket will
	// get logged for EditAttemptStep init events
	if ( conf.editCheck && conf.editCheckABTest ) {
		let inABTest;
		if ( mw.user.isAnon() ) {
			// can't just use mw.user.sessionId() because we need this to last across sessions
			const token = mw.cookie.get( 'VEECid', '', mw.user.generateRandomSessionId() );
			// Store the token so our state is consistent across pages
			mw.cookie.set( 'VEECid', token, { path: '/', expires: 90 * 86400, prefix: '' } );
			inABTest = parseInt( token.slice( 0, 8 ), 16 ) % 2 === 1;
		} else {
			inABTest = mw.user.getId() % 2 === 1;
		}
		conf.editCheckABTestGroup = inABTest ? 'test' : 'control';
		// Communicate the bucket to instrumentation:
		mw.config.set(
			'wgVisualEditorEditCheckABTestBucket',
			'2025-09-editcheck-' + conf.editCheckABTest + '-' + ( inABTest ? 'test' : 'control' )
		);
	}

	let ecenable = url.searchParams.get( 'ecenable' );
	if ( window.MWVE_FORCE_EDIT_CHECK_ENABLED && ecenable !== '0' ) {
		ecenable = window.MWVE_FORCE_EDIT_CHECK_ENABLED;
	}
	const editCheck = conf.editCheck || !!ecenable;
	if ( conf.editCheckTagging || editCheck ) {
		modules.push(
			'ext.visualEditor.editCheck',
			'ext.visualEditor.editCheck.checks'
		);
	}

	const namespaces = mw.config.get( 'wgNamespaceIds' );
	// Load signature tool if *any* namespace supports it.
	// It will be shown disabled on namespaces that don't support it.
	if (
		Object.keys( namespaces ).some( ( name ) => mw.Title.wantSignaturesNamespace( namespaces[ name ] ) )
	) {
		modules.push( 'ext.visualEditor.mwsignature' );
	}

	mw.libs.ve.targetLoader = {
		/**
		 * Add a plugin module or callback.
		 *
		 * If a module name is passed, that module will be loaded alongside the other modules.
		 *
		 * If a callback is passed, it will be executed after the modules have loaded. The callback
		 * may optionally return a jQuery.Promise; if it does, loading won't be complete until
		 * that promise is resolved.
		 *
		 * @param {string|Function} plugin Plugin module name or callback
		 */
		addPlugin: function ( plugin ) {
			if ( typeof plugin === 'string' ) {
				modules.push( plugin );
			} else {
				pluginCallbacks.push( plugin );
			}
		},

		/**
		 * Load modules needed for VisualEditor, as well as plugins.
		 *
		 * This loads the base VE modules as well as any registered plugin modules.
		 * Once those are loaded, any registered plugin callbacks are executed,
		 * and we wait for all promises returned by those callbacks to resolve.
		 *
		 * @param {string} mode Initial editor mode, for tracking
		 * @return {jQuery.Promise} Promise resolved when the loading process is complete
		 */
		loadModules: function ( mode ) {
			mw.hook( 've.loadModules' ).fire( this.addPlugin.bind( this ) );
			ve.track( 'trace.moduleLoad.enter', { mode } );
			return mw.loader.using( modules )
				.then( () => {
					ve.track( 'trace.moduleLoad.exit', { mode } );
					pluginCallbacks.push( ve.init.platform.getInitializedPromise.bind( ve.init.platform ) );
					// Execute plugin callbacks and collect promises
					return $.when( ...pluginCallbacks.map( ( callback ) => {
						try {
							return callback();
						} catch ( e ) {
							mw.log.warn( 'Failed to load VE plugin:', e );
							return null;
						}
					} ) );
				} );
		},

		/**
		 * Creates an OOUI checkbox inside an inline field layout
		 *
		 * @param {Object[]} checkboxesDef Checkbox definitions from the API
		 * @param {Object} [widgetConfig] Additional widget config
		 * @return {Object} Result object with checkboxFields (OO.ui.FieldLayout[]) and
		 *  checkboxesByName (keyed object of OO.ui.InputWidget).
		 */
		createCheckboxFields: function ( checkboxesDef, widgetConfig ) {
			const checkboxFields = [],
				checkboxesByName = {};

			if ( checkboxesDef ) {
				Object.keys( checkboxesDef ).forEach( ( name ) => {
					const options = checkboxesDef[ name ];
					let accessKey = null,
						title = null;

					// The messages documented below are just the ones defined in core.
					// Extensions may add other checkboxes.
					if ( options.tooltip ) {
						// The following messages are used here:
						// * accesskey-minoredit
						// * accesskey-watch
						accessKey = mw.message( 'accesskey-' + options.tooltip ).text();
						// The following messages are used here:
						// * tooltip-minoredit
						// * tooltip-watch
						title = mw.message( 'tooltip-' + options.tooltip ).text();
					}
					if ( options[ 'title-message' ] ) {
						// Not used in core
						// eslint-disable-next-line mediawiki/msg-doc
						title = mw.message( options[ 'title-message' ] ).text();
					}
					// The following messages are used here:
					// * minoredit
					// * watchthis
					const $label = mw.message( options[ 'label-message' ] ).parseDom();

					const config = $.extend( {
						accessKey,
						// The following classes are used here:
						// * ve-ui-mwSaveDialog-checkbox-wpMinoredit
						// * ve-ui-mwSaveDialog-checkbox-wpWatchthis
						// * ve-ui-mwSaveDialog-checkbox-wpWatchlistExpiry
						// * ve-ui-mwSaveDialog-checkbox-wpWatchlistLabels
						classes: [ 've-ui-mwSaveDialog-checkbox-' + name ]
					}, widgetConfig );

					let checkbox;
					switch ( options.class ) {
						case 'OOUI\\DropdownInputWidget':
							checkbox = new OO.ui.DropdownInputWidget( $.extend( config, {
								value: options.default,
								options: options.options
							} ) );
							break;

						case 'MediaWiki\\Widget\\MenuTagMultiselectWidget':
							checkbox = new mw.widgets.MenuTagMultiselectWidget( $.extend( config, {
								name,
								options: options.options || {},
								selected: options.default || [],
								allowReordering: !!options.allowReordering,
								allowArbitrary: false,
								inputPosition: options.inputPosition || 'outline',
								// eslint-disable-next-line mediawiki/msg-doc
								placeholder: options[ 'placeholder-message' ] ? mw.message( options[ 'placeholder-message' ] ).text() : undefined
							} ) );
							break;

						default:
							checkbox = new OO.ui.CheckboxInputWidget( $.extend( config, {
								selected: options.default
							} ) );
							break;
					}

					const fieldConfig = {
						align: options.align || 'inline',
						label: $label,
						title,
						invisibleLabel: !!options.invisibleLabel,
						// * ve-ui-mwSaveDialog-field-wpMinoredit
						// * ve-ui-mwSaveDialog-field-wpWatchthis
						// * ve-ui-mwSaveDialog-field-wpWatchlistExpiry
						// * ve-ui-mwSaveDialog-field-wpWatchlistLabels
						classes: [ 've-ui-mwSaveDialog-field-' + name ]
					};
					if ( options[ 'help-message' ] ) {
						// eslint-disable-next-line mediawiki/msg-doc
						fieldConfig.help = mw.message( options[ 'help-message' ] ).parseDom();
						fieldConfig.helpInline = true;
					}

					checkboxFields.push(
						new OO.ui.FieldLayout( checkbox, fieldConfig )
					);
					checkboxesByName[ name ] = checkbox;
				} );
			}
			return {
				checkboxFields,
				checkboxesByName
			};
		},

		/**
		 * Request the page data and various metadata from the MediaWiki API (which will use
		 * Parsoid or RESTBase).
		 *
		 * @param {string} mode Target mode: 'visual' or 'source'
		 * @param {string} pageName Page name to request, in prefixed DB key form (underscores instead of spaces)
		 * @param {Object} [options]
		 * @param {boolean} [options.sessionStore] Store result in session storage (by page+mode+section) for auto-save
		 * @param {null|string} [options.section] Section to edit; number, 'T-'-prefixed, null or 'new' (currently just source mode)
		 * @param {number} [options.oldId] Old revision ID. Current if omitted.
		 * @param {string} [options.targetName] Optional target name for tracking
		 * @param {boolean} [options.modified] The page has been modified before loading (e.g. in source mode)
		 * @param {string} [options.wikitext] Wikitext to convert to HTML. The original document is fetched if undefined.
		 * @param {string} [options.editintro] Name of a page to use as edit intro message
		 * @param {string} [options.preload] Name of a page to use as preloaded content if pageName is empty
		 * @param {string[]} [options.preloadparams] Parameters to substitute into preload if it's used
		 * @return {jQuery.Promise} Abortable promise resolved with a JSON object
		 */
		requestPageData: function ( mode, pageName, options = {} ) {
			if ( mode === 'visual' && options.section === 'new' ) {
				throw new Error( 'Adding new section is not supported in visual mode' );
			}
			const apiRequest = mode === 'source' ?
				this.requestWikitext.bind( this, pageName, options ) :
				this.requestParsoidData.bind( this, pageName, options );

			if ( options.sessionStore ) {
				let sessionState;
				try {
					// ve.init.platform.getSessionObject is not available yet
					sessionState = JSON.parse( mw.storage.session.get( 've-docstate' ) );
				} catch ( e ) {}

				if ( sessionState ) {
					const request = sessionState.request || {};
					// Check true section editing is in use
					const enableVisualSectionEditing = conf.enableVisualSectionEditing;
					const section = request.mode === 'source' || enableVisualSectionEditing === true || enableVisualSectionEditing === options.targetName ?
						options.section : null;
					// Check the requested page, mode and section match the stored one
					if (
						request.pageName === pageName &&
						request.mode === mode &&
						request.section === section
						// NB we don't cache by oldid so that cached results can be recovered
						// even if the page has been since edited
					) {
						const dataPromise = $.Deferred().resolve( {
							visualeditor: $.extend(
								{ content: mw.storage.session.get( 've-dochtml' ) },
								sessionState.response,
								{ recovered: true }
							)
						} ).promise();
						// If the document hasn't been edited since the user first loaded it, recover
						// their changes automatically.
						if ( sessionState.response.oldid === mw.config.get( 'wgCurRevisionId' ) ) {
							return dataPromise;
						} else {
							// Otherwise, prompt them if they want to recover, or reload the document
							// to see the latest version
							// This prompt will throw off all of our timing data, so just disable tracking
							// for this session
							ve.track = function () {};
							return mw.loader.using( 'oojs-ui-windows' ).then( () => OO.ui.confirm( mw.msg( 'visualeditor-autosave-modified-prompt-message' ), {
								title: mw.msg( 'visualeditor-autosave-modified-prompt-title' ),
								actions: [
									{ action: 'accept', label: mw.msg( 'visualeditor-autosave-modified-prompt-accept' ), flags: [ 'primary', 'progressive' ] },
									{ action: 'reject', label: mw.msg( 'visualeditor-autosave-modified-prompt-reject' ), flags: 'destructive' }
								] }
							).then( ( confirmed ) => {
								if ( confirmed ) {
									return dataPromise;
								} else {
									// If they requested the latest version, invalidate the autosave state
									mw.storage.session.remove( 've-docstate' );
									return apiRequest();
								}
							} ) );
						}
					}
				}
			}

			return apiRequest();
		},

		/**
		 * Request the page HTML and various metadata from the MediaWiki API (which will use
		 * Parsoid or RESTBase).
		 *
		 * @param {string} pageName See #requestPageData
		 * @param {Object} [options] See #requestPageData
		 * @param {boolean} [noRestbase=false] Don't query RESTBase directly
		 * @param {boolean} [noMetadata=false] Don't fetch document metadata when querying RESTBase. Metadata
		 *  is not required for some use cases, e.g. diffing.
		 * @return {jQuery.Promise} Abortable promise resolved with a JSON object
		 */
		requestParsoidData: function ( pageName, options = {}, noRestbase = false, noMetadata = false ) {
			const section = options.section !== undefined ? options.section : null,
				useRestbase = !noRestbase && ( conf.fullRestbaseUrl || conf.restbaseUrl ) && section === null;

			const data = {
				action: 'visualeditor',
				paction: useRestbase ? 'metadata' : 'parse',
				page: pageName,
				badetag: options.badetag,
				uselang: mw.config.get( 'wgUserLanguage' ),
				editintro: options.editintro,
				preload: options.preload,
				preloadparams: options.preloadparams,
				formatversion: 2
			};

			// Only request the API to explicitly load the currently visible revision if we're restoring
			// from oldid. Otherwise we should load the latest version. This prevents us from editing an
			// old version if an edit was made while the user was viewing the page and/or the user is
			// seeing (slightly) stale cache.
			if ( options.oldId !== undefined ) {
				data.oldid = options.oldId;
			}
			// Load DOM
			const start = ve.now();
			ve.track( 'trace.apiLoad.enter', { mode: 'visual' } );

			let apiXhr, apiPromise;
			let switched = false,
				fromEditedState = false;
			if ( !useRestbase && options.wikitext !== undefined ) {
				// Non-RESTBase custom wikitext parse
				data.paction = 'parse';
				data.stash = true;
				switched = true;
				fromEditedState = options.modified;
				data.wikitext = options.wikitext;
				data.section = options.section;
				data.oldid = options.oldId;
				apiXhr = new mw.Api().post( data );
			} else {
				if ( useRestbase && noMetadata ) {
					apiPromise = $.Deferred().resolve( { visualeditor: {} } ).promise();
				} else {
					apiXhr = new mw.Api().get( data );
				}
			}
			if ( !apiPromise ) {
				apiPromise = apiXhr.then( ( response ) => {
					ve.track( 'trace.apiLoad.exit', { mode: 'visual' } );
					mw.track( 'timing.ve.' + options.targetName + '.performance.system.apiLoad',
						ve.now() - start );
					if ( response.visualeditor ) {
						response.visualeditor.switched = switched;
						response.visualeditor.fromEditedState = fromEditedState;
					}
					return response;
				} );
			}

			let dataPromise, abort;
			if ( useRestbase ) {
				ve.track( 'trace.restbaseLoad.enter', { mode: 'visual' } );

				const headers = {
					// Should be synchronised with DirectParsoidClient.php
					Accept: 'text/html; charset=utf-8; profile="https://www.mediawiki.org/wiki/Specs/HTML/2.8.0"',
					'Accept-Language': mw.config.get( 'wgVisualEditor' ).pageLanguageCode,
					'Api-User-Agent': 'VisualEditor-MediaWiki/' + mw.config.get( 'wgVersion' )
				};

				let restbaseXhr, pageHtmlUrl;
				// Convert specified Wikitext to HTML
				if (
					// wikitext can be an empty string
					options.wikitext !== undefined &&
					// eslint-disable-next-line no-jquery/no-global-selector
					!$( '[name=wpSection]' ).val()
				) {
					if ( conf.fullRestbaseUrl ) {
						pageHtmlUrl = conf.fullRestbaseUrl + 'v1/transform/wikitext/to/html/';
					} else {
						pageHtmlUrl = conf.restbaseUrl.replace( 'v1/page/html/', 'v1/transform/wikitext/to/html/' );
					}
					switched = true;
					fromEditedState = options.modified;
					window.onbeforeunload = null;
					$( window ).off( 'beforeunload' );
					restbaseXhr = $.ajax( {
						url: pageHtmlUrl + encodeURIComponent( pageName ) +
							( data.oldid === undefined ? '' : '/' + data.oldid ),
						type: 'POST',
						data: {
							title: pageName,
							wikitext: options.wikitext,
							stash: 'true'
						},
						headers,
						dataType: 'text'
					} );
				} else {
					// Fetch revision
					if ( conf.fullRestbaseUrl ) {
						pageHtmlUrl = conf.fullRestbaseUrl + 'v1/page/html/';
					} else {
						pageHtmlUrl = conf.restbaseUrl;
					}
					restbaseXhr = $.ajax( {
						url: pageHtmlUrl + encodeURIComponent( pageName ) +
							( data.oldid === undefined ? '' : '/' + data.oldid ) +
							'?redirect=false&stash=true',
						type: 'GET',
						headers,
						dataType: 'text'
					} );
				}
				const restbasePromise = restbaseXhr.then(
					( response, status, jqxhr ) => {
						ve.track( 'trace.restbaseLoad.exit', { mode: 'visual' } );
						mw.track( 'timing.ve.' + options.targetName + '.performance.system.restbaseLoad',
							ve.now() - start );
						return [ response, jqxhr.getResponseHeader( 'etag' ) ];
					},
					( xhr, code, _ ) => {
						if ( xhr.status === 404 ) {
							// Page does not exist, so let the user start with a blank document.
							return $.Deferred().resolve( [ '', undefined ] ).promise();
						} else {
							mw.log.warn( 'RESTBase load failed: ' + xhr.statusText );
							return $.Deferred().reject( code, xhr, _ ).promise();
						}
					}
				);

				dataPromise = $.when( apiPromise, restbasePromise )
					.then( ( apiData, restbaseData ) => {
						if ( apiData.visualeditor ) {
							if ( restbaseData[ 0 ] || !apiData.visualeditor.content ) {
								// If we have actual content loaded, use it.
								// Otherwise, allow fallback content if present.
								// If no fallback content, this will give us an empty string for
								// content, which is desirable.
								apiData.visualeditor.content = restbaseData[ 0 ];
								apiData.visualeditor.etag = restbaseData[ 1 ];
							}
							apiData.visualeditor.switched = switched;
							apiData.visualeditor.fromEditedState = fromEditedState;
						}
						return apiData;
					} );
				abort = function () {
					if ( apiXhr ) {
						apiXhr.abort();
					}
					restbaseXhr.abort();
				};
			} else {
				dataPromise = apiPromise;
				if ( apiXhr ) {
					abort = apiXhr.abort;
				}
			}

			return dataPromise.then( ( resp ) => {
				// Adapted from RESTBase mwUtil.parseETag()
				const etagRegexp = /^(?:W\/)?"?([^"/]+)(?:\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))(?:\/([^"]+))?"?$/;

				// `etag` is expected to be undefined when creating a new page.
				// We can detect that case by `content` being empty, and not retry.
				if ( useRestbase && resp.visualeditor.content && (
					!resp.visualeditor.etag ||
					!etagRegexp.test( resp.visualeditor.etag )
				) ) {
					// Direct request to RESTBase returned a mangled or missing etag.
					// Retry via the MediaWiki API.
					return mw.libs.ve.targetLoader.requestParsoidData(
						pageName,
						$.extend( {}, options, { badetag: resp.visualeditor.etag || '' } ),
						true
					);
				}

				resp.veMode = 'visual';
				return resp;
			} ).promise( { abort } );
		},

		/**
		 * Request the page wikitext and various metadata from the MediaWiki API.
		 *
		 * @param {string} pageName See #requestPageData
		 * @param {Object} [options] See #requestPageData
		 * @return {jQuery.Promise} Abortable promise resolved with a JSON object
		 */
		requestWikitext: function ( pageName, options = {} ) {
			const data = {
				action: 'visualeditor',
				paction: 'wikitext',
				page: pageName,
				uselang: mw.config.get( 'wgUserLanguage' ),
				editintro: options.editintro,
				preload: options.preload,
				preloadparams: options.preloadparams,
				formatversion: 2
			};

			// section should never really be undefined, but check just in case
			if ( options.section !== null && options.section !== undefined ) {
				data.section = options.section;
			}

			if ( options.oldId !== undefined ) {
				data.oldid = options.oldId;
			}

			const dataPromise = new mw.Api().get( data );
			return dataPromise.then( ( resp ) => {
				resp.veMode = 'source';
				return resp;
			} ).promise( { abort: dataPromise.abort } );
		}
	};
}() );
