/**
 * Perform a REST API request to reveal the IP address(es) used for given revIds, logIds,
 * and aflIds performed by temporary accounts. If no ids are specified, this will return
 * the last IP address used by a temporary account.
 *
 * @param {string} target
 * @param {Object} revIds
 * @param {Object} logIds
 * @param {Object} aflIds
 * @param {string} [wikiUrl] The URL pattern for an external wiki in the format
 *  returned by PHP's Site::getPath, or undefined if querying the local wiki
 * @param {boolean} [retryOnTokenMismatch]
 * @return {Promise}
 */
function performRevealRequest( target, revIds, logIds, aflIds, wikiUrl, retryOnTokenMismatch ) {
	if ( retryOnTokenMismatch === undefined ) {
		// Default value for the argument is true.
		retryOnTokenMismatch = true;
	}

	const deferred = $.Deferred();

	/**
	 * Takes an object with array of integers and turns it into an array of strings.
	 *
	 * This is used when building requests for fetching data by log IDs, since
	 * these IDs are integers, but the backend API expects strings.
	 *
	 * @param {{allIds: number[] | null} | null} ids Values to cast into strings
	 * @return {string[]}
	 */
	const makeStringIDs = ( ids ) => {
		if ( !ids || !ids.allIds || !Array.isArray( ids.allIds ) ) {
			return [];
		}
		return ids.allIds.map( ( id ) => id.toString() );
	};

	const request = {
		[ target ]: {
			revIds: makeStringIDs( revIds ),
			logIds: makeStringIDs( logIds ),
			lastUsedIp: true
		}
	};

	if ( mw.config.get( 'wgCheckUserAbuseFilterExtensionLoaded' ) ) {
		request[ target ].abuseLogIds = makeStringIDs( aflIds );
	}

	performBatchRevealRequestInternal( request, wikiUrl, retryOnTokenMismatch )
		.then( ( data ) => {
			let key;
			if ( isAbuseFilterLogLookup( aflIds ) ) {
				key = 'abuseLogIps';
			} else if ( isRevisionLookup( revIds ) ) {
				key = 'revIps';
			} else if ( isLogLookup( logIds ) ) {
				key = 'logIps';
			} else {
				key = 'lastUsedIp';
			}

			// Adjust the response format to what's expected by the caller
			if ( !Object.prototype.hasOwnProperty.call( data, target ) ||
				!Object.prototype.hasOwnProperty.call( data[ target ], key ) ) {
				throw new Error( 'Malformed response' );
			}

			// Request was made without any IDs, so return the last used IP
			if ( key === 'lastUsedIp' ) {
				return deferred.resolve( {
					ips: [ data[ target ].lastUsedIp ],
					autoReveal: data.autoReveal
				} );
			}

			deferred.resolve( {
				ips: data[ target ][ key ],
				autoReveal: data.autoReveal
			} );
		} ).catch( ( err ) => {
			deferred.reject( err, {} );
		} );

	return deferred.promise();
}

/**
 * Perform a REST API request to reveal all the IP addresses used by a temporary account.
 *
 * @param {string} target
 * @param {boolean} [retryOnTokenMismatch]
 * @return {Promise}
 */
function performFullRevealRequest( target, retryOnTokenMismatch ) {
	const restApi = new mw.Rest();
	const api = new mw.Api();
	const deferred = $.Deferred();

	if ( retryOnTokenMismatch === undefined ) {
		// Default value for the argument is true.
		retryOnTokenMismatch = true;
	}

	api.getToken( 'csrf' ).then( ( token ) => {
		restApi.post(
			'/checkuser/v0/temporaryaccount/' + target,
			{ token: token } )
			.then(
				( data ) => {
					deferred.resolve( data );
				},
				( err, errObject ) => {
					if ( retryOnTokenMismatch && isBadTokenError( errObject ) ) {
						// The CSRF token has expired. Retry the POST with a new token.
						api.badToken( 'csrf' );
						performFullRevealRequest( target, false ).then(
							( data ) => {
								deferred.resolve( data );
							},
							( secondRequestErr, secondRequestErrObject ) => {
								deferred.reject( secondRequestErr, secondRequestErrObject );
							}
						);
					} else {
						deferred.reject( err, errObject );
					}
				}
			);
	} );

	return deferred.promise();
}

/**
 * @typedef {Object} RevealRequest
 * @property {string[]} revIds
 * @property {string[]} logIds
 * @property {boolean} lastUsedIp
 */

/** @typedef {Map<string, RevealRequest>} BatchRevealRequest */

/** @type {Object<string, Promise>} */
const requests = {};

/**
 * Reveal multiple IP addresses in a single request.
 *
 * @param {BatchRevealRequest} request
 * @param {string} [wikiUrl] The URL pattern for an external wiki in the format
 *  returned by PHP's Site::getPath, or undefined if querying the local wiki
 * @param {boolean} [retryOnTokenMismatch]
 * @return {Promise}
 */
function performBatchRevealRequest( request, wikiUrl, retryOnTokenMismatch ) {
	if ( retryOnTokenMismatch === undefined ) {
		// Default value for the argument is true.
		retryOnTokenMismatch = true;
	}

	// De-duplicate requests to the same wiki using the same request parameters.
	const serialized = JSON.stringify( { url: wikiUrl, request: request } );
	if ( Object.prototype.hasOwnProperty.call( requests, serialized ) ) {
		return requests[ serialized ];
	}

	const requestPromise = performBatchRevealRequestInternal(
		request,
		wikiUrl,
		retryOnTokenMismatch
	)
		.then( ( response ) => {
			delete requests[ serialized ];
			return response;
		} )
		.catch( ( err ) => {
			delete requests[ serialized ];
			return err;
		} );

	requests[ serialized ] = requestPromise;

	return requestPromise;
}

/**
 * @param {BatchRevealRequest} request
 * @param {string|undefined} wikiUrl The URL pattern for an external wiki in the format
 *  returned by PHP's Site::getPath, or undefined if querying the local wiki
 * @param {boolean} retryOnTokenMismatch
 * @return {Promise}
 */
function performBatchRevealRequestInternal( request, wikiUrl, retryOnTokenMismatch ) {
	const deferred = $.Deferred();

	let api, restApi;
	if ( wikiUrl ) {
		api = new mw.ForeignApi( wikiUrl.replace( '$1', 'api.php' ) );
		restApi = new mw.ForeignRest( wikiUrl.replace( '$1', 'rest.php' ), api );
	} else {
		api = new mw.Api();
		restApi = new mw.Rest();
	}

	api.getToken( 'csrf' ).then( ( token ) => {
		restApi.post( '/checkuser/v0/batch-temporaryaccount', { token: token, users: request } ).then(
			( data ) => {
				deferred.resolve( data );
			},
			( err, errObject ) => {
				if ( retryOnTokenMismatch && isBadTokenError( errObject ) ) {
					// The CSRF token has expired. Retry the POST with a new token.
					api.badToken( 'csrf' );
					performBatchRevealRequestInternal( request, wikiUrl, false ).then(
						( data ) => {
							deferred.resolve( data );
						},
						( secondRequestErr, secondRequestErrObject ) => {
							deferred.reject( secondRequestErr, secondRequestErrObject );
						}
					);
				} else {
					deferred.reject( err, errObject );
				}
			}
		);
	} ).catch( ( err, errObject ) => {
		deferred.reject( err, errObject );
	} );

	return deferred.promise();
}

/**
 * Determine whether to look up IPs for revision IDs.
 *
 * @param {Object} revIds
 * @return {boolean} There are revision IDs
 */
function isRevisionLookup( revIds ) {
	return !!( revIds && revIds.allIds && revIds.allIds.length );
}

/**
 * Determine whether to look up IPs for log IDs.
 *
 * @param {Object} logIds
 * @return {boolean} There are log IDs
 */
function isLogLookup( logIds ) {
	return !!( logIds && logIds.allIds && logIds.allIds.length );
}

/**
 * Determine whether to look up IPs for AbuseFilter log IDs.
 *
 * @param {Object} aflIds
 * @return {boolean} There are revision IDs
 */
function isAbuseFilterLogLookup( aflIds ) {
	return !!( aflIds && aflIds.allIds && aflIds.allIds.length );
}

/**
 * Checks if an error response is caused by providing a bad CSRF token.
 *
 * @param {Object} errObject
 * @return {boolean}
 * @internal
 */
function isBadTokenError( errObject ) {
	return errObject.xhr &&
		errObject.xhr.responseJSON &&
		errObject.xhr.responseJSON.errorKey &&
		errObject.xhr.responseJSON.errorKey === 'rest-badtoken';
}

module.exports = {
	performRevealRequest: performRevealRequest,
	performFullRevealRequest: performFullRevealRequest,
	performBatchRevealRequest: performBatchRevealRequest,
	isRevisionLookup: isRevisionLookup,
	isLogLookup: isLogLookup,
	isAbuseFilterLogLookup: isAbuseFilterLogLookup
};
