// ==UserScript==
// @author       µKöff
// @grant        GM_notification
// @grant        GM_registerMenuCommand
// @homepageURL  https://gitlab.com/muekoeff/wikidatapain/
// @icon         https://www.wikidata.org/static/favicon/wikidata.ico
// @license      MIT
// @match        https://test.wikidata.org/*
// @match        https://www.wikidata.org/*
// @name         WikidataPain
// @namespace    https://muekev.de/
// @version      1.2
// ==/UserScript==

/**
 * Main class
 *
 * @hideconstructor
 */
class WikidataPain {
	/**
	 * Wether to display an alert once a batch has finished.
	 *
	 * @type {boolean}
	 */
	static BATCH_ALERT_ON_FINISHED = false;

	/**
	 * API-endpoint used for API calls.
	 *
	 * @type {string}
	 */
	static ENDPOINT = `https://${window.location.hostname === 'test.wikidata.org' ? 'test.wikidata.org' : 'www.wikidata.org'}/w/api.php`;
	/**
	 * Maxlag used when making API calls.
	 *
	 * @type {number}
	 */
	static MAXLAG = 10;

	/**
	 * Display a notification after every `n`-th finished action.
	 * Set to `false` to disable.
	 *
	 * @type {boolean|number}
	 */
	static NOTIFICATIONS_PROGRESS = 1;

	static _batchStop = false;
	static #interceptorLog = [];
	static #interceptorOldPost = null;

	/**
	 * Command as used by adhoc-mode.
	 *
	 * @typedef {Object} AdhocCommand
	 * @property {string} action
	 * @property {Object} [claim]
	 * @property {AdhocConfig} [config]
	 */

	/**
	 * Config as used by adhoc-mode.
	 *
	 * @typedef {Object} AdhocConfig
	 * @property {WikidataPain.StatementEditMode|string} [claimId]
	 * @property {any} [entity]
	 * @property {Array<string>} [ignoredQualifierProperties]
	 */

	/**
	 * Optional configurations.
	 *
	 * @typedef {Object} Config
	 * @property {number} [editGroup] EditGroup-id this operation is associated with.
	 * @property {number} [probe] Pause after each batch item for n items.
	 * @property {string} [summary] Summary of this operation.
	 */

	/**
	 * @typedef {Object} DissectedTime
	 * @property {number} day
	 * @property {number} month
	 * @property {number} precision
	 * @property {string} time
	 * @property {number} year
	 */

	/**
	 * @private
	 * @readonly
	 * @enum {string}
	 */
	static ConfigKey = Object.freeze( {
		compoundSummary: 'compoundSummary',
		editGroup: 'editGroup',
		probe: 'probe',
		summary: 'summary'
	} );

	/**
	 * @readonly
	 * @enum {string}
	 */
	static ReferenceMode = Object.freeze( {
		equal: 'equal',
		merge: 'merge',
		override: 'override'
	} );

	/**
	 * @readonly
	 * @enum {string}
	 */
	static StatementEditMode = Object.freeze( {
		append: 'append',
		appendOrNew: 'append_or_new',
		completeSubset: 'complete_subset',
		completeSubsetOrNew: 'complete_subset_or_new',
		new: 'new'
	} );

	/**
	 * @param {Object|string} commands
	 * @param {number} probe
	 * @return {Promise<void>}
	 */
	static adhoc( commands, probe ) {
		return new Promise( function ( resolve, reject ) {
			if ( commands === undefined || commands === null ) {
				const commandsRaw = prompt( 'Please paste your commands.' );
				if ( commandsRaw === null ) {
					return;
				}
				try {
					commands = JSON.parse( commandsRaw );
				} catch ( ex ) {
					alert( 'Unable to parse input.' );
					return;
				}
			}
			WikidataPain.batch( commands, function ( claim ) {
				return new Promise( function ( batchResolve, batchReject ) {
					const promise = WikidataPain.adhocCommand( claim );
					if ( promise === null ) {
						batchResolve();
					} else {
						promise.then( batchResolve, batchReject );
					}
				} );
			}, {
				probe: probe
			} ).then( resolve, reject );
		} );
	}

	/**
	 * Executes a single adhoc-command.
	 *
	 * @param {Object} command
	 * @return {Promise<void>}
	 */
	static adhocCommand( command ) {
		switch ( command.action ) {
			case 'wbsetclaim':
				return new Promise( function ( resolve, reject ) {
					WikidataPain.adhocPreprocess( command ).then( function ( preparedCommand ) {
						preparedCommand.claim = JSON.stringify( preparedCommand.claim );
						WikidataPain.queryCsrfToken().then( function ( token ) {
							$.extend( preparedCommand, {
								format: 'json',
								maxlag: WikidataPain.MAXLAG,
								token: token
							} );
							$.ajax( {
								data: preparedCommand,
								method: 'POST',
								url: WikidataPain.ENDPOINT
							} ).done( function ( data ) {
								if ( !Object.prototype.hasOwnProperty.call( data, 'error' ) && data.success === 1 ) {
									resolve( data );
								} else {
									reject( data );
								}
							} ).fail( function ( data ) {
								reject( data );
							} );
						}, reject );
					}, reject );
				} );
			case 'wbremoveclaims':
				return new Promise( function ( resolve, reject ) {
					WikidataPain.adhocPreprocess( command ).then( function ( preparedCommand ) {
						WikidataPain.queryCsrfToken().then( function ( token ) {
							$.extend( command, {
								format: 'json',
								maxlag: WikidataPain.MAXLAG,
								token: token
							} );
							$.ajax( {
								data: preparedCommand,
								method: 'POST',
								url: WikidataPain.ENDPOINT
							} ).done( function ( data ) {
								if ( !Object.prototype.hasOwnProperty.call( data, 'error' ) && data.success === 1 ) {
									resolve( data );
								} else {
									reject( data );
								}
							} ).fail( reject );
						}, reject );
					}, reject );
				} );
			default:
				alert( `Unknown action ${command.action}.` );
				return null;
		}
	}

	/**
	 * @param {AdhocCommand} command
	 * @return {Promise<Object>}
	 */
	static adhocPreprocess( command ) {
		return new Promise( function ( resolve, reject ) {
			if ( !validateCommand() ) {
				reject();
				return;
			}
			if ( 'claim' in command && [ 'wbsetclaim' ].includes( command.action ) && typeof command.claim !== 'object' ) {
				command.claim = JSON.parse( command.claim );
			}
			if ( 'config' in command ) {
				if ( 'entity' in command.config && command.config.entity === 'current' ) {
					command.config.entity = WikidataPain.Util.currentEntityId();
				}
				if ( 'claimId' in command.config ) {
					if ( !( 'entity' in command.config ) ) {
						console.error( 'config.entity not set' );
						reject();
						return;
					}
					// @TODO: Implement references
					const claimId = command.config.claimId;
					const property = command.claim.mainsnak.property;
					const entity = command.config.entity;
					switch ( claimId ) {
						case WikidataPain.StatementEditMode.append: // Require that there's a claim with the same value and use its uuid
						case WikidataPain.StatementEditMode.appendOrNew: // If there's a claim with the same value use its uuid, otherwise a random one
							WikidataPain.Statement.fromEntityCollapsed( entity, property ).then( function ( claims ) {
								WikidataPain.Statement.filterCollapsed( claims, function ( claim ) {
									return claim.filterByMainsnakDataValue( async function ( dataValue ) {
										return dataValue.equals( command.claim.mainsnak.value );
									} );
								} ).then( function ( filteredClaims ) {
									if ( filteredClaims.length === 0 ) {
										if ( claimId === WikidataPain.StatementEditMode.appendOrNew ) {
											command.claim.id = WikidataPain.Util.randomClaimId( entity );
										} else {
											reject();
											return;
										}
									} else {
										command.claim.id = filteredClaims[ 0 ].id;
										mergeReferencesInto( command.claim, filteredClaims[ 0 ] );
									}
									resolve();
									return;
								}, reject );
							}, reject );
							break;
						case WikidataPain.StatementEditMode.completeSubset: // Require that there's a claim with the same value and a subset of qualifiers and use its uuid
						case WikidataPain.StatementEditMode.completeSubsetOrNew: // If there's a claim with the same value and a subset of qualifiers use its uuid, otherwise a random one
							WikidataPain.getClaimsByEntity( entity, property ).then( function ( data ) {
								WikidataPain.Statement.filterStatementsByPropertiesSubset( data.claims, property, command.claim, command.config.ignoredQualifierProperties ).then( function ( wdClaims ) {
									if ( wdClaims.length === 0 ) {
										if ( claimId === WikidataPain.StatementEditMode.completeSubsetOrNew ) {
											command.claim.id = WikidataPain.Util.randomClaimId( entity );
										} else {
											reject();
										}
									} else {
										command.claim.id = wdClaims[ 0 ].id;
										mergeReferencesInto( command.claim, wdClaims[ 0 ] );
									}
									resolve();
								}, reject );
							}, reject );
							break;
						case WikidataPain.StatementEditMode.new: // Use a random uuid creating a new claim
							command.claim.id = WikidataPain.Util.randomClaimId( entity );
							resolve();
							return;
						default:
							console.error( `Unknown config.claimId: ${claimId}` );
							reject();
							return;
					}
				}
			} else {
				resolve( command );
				return;
			}
		} );

		function mergeReferencesInto( from, into ) {
			if ( !( 'references' in into ) ) {
				return;
			} else if ( !( 'references' in from ) ) {
				from.references = into.references;
			} else {
				throw new Error( 'Not implemented' );
			}
		}

		function validateCommand() {
			if ( 'config' in command ) {
				if ( !Object.keys( command.config ).every( function ( key ) {
					return [ 'claimId', 'entity', 'ignoredQualifierProperties' ].includes( key );
				} ) ) {
					console.error( 'There are unknown configs' );
					return false;
				}
			}
			return true;
		}
	}

	/**
	 * @template T
	 * @param {Array<T>} claims
	 * @param {function(T): Promise<(string|void)?>} action
	 * @param {Config} [config]
	 * @return {Promise<void>}
	 */
	static batch( claims, action, config ) {
		return new Promise( function ( resolve, reject ) {
			WikidataPain._batch( 0, claims, action, config, resolve, reject );
		} );
	}

	/**
	 * Stops all running batches after they've finished their currently running task.
	 */
	static batchRequestStop() {
		WikidataPain._batchStop = true;
	}

	/**
	 * @private
	 * @template T
	 * @param {number} offset
	 * @param {Array<T>} claims
	 * @param {function(T): Promise<(string|void)?>} action
	 * @param {Config} config
	 * @param {Function} resolve
	 * @param {Function} reject
	 */
	static _batch( offset, claims, action, config, resolve, reject ) {
		const probe = WikidataPain.getConfig( config, WikidataPain.ConfigKey.probe, claims.length > 10 ? 1 : 0 );

		if ( WikidataPain._batchStop && offset < claims.length ) {
			alert( `Stopped at ${offset + 1}/${claims.length}.` );
			reject();
			return;
		}

		if ( offset < claims.length ) {
			action( claims[ offset ] ).then( function ( actionResult ) {
				console.log( `${offset + 1}/${claims.length} finished.` );
				if ( WikidataPain.NOTIFICATIONS_PROGRESS > 0 && ( offset + 1 ) % +WikidataPain.NOTIFICATIONS_PROGRESS === 0 ) {
					GM_notification( `${offset + 1}/${claims.length}${typeof actionResult === 'string' ? ` ${actionResult}` : ''}`, 'WikidataPain' );
				}
				if ( offset < probe ) {
					alert( `Finished ${offset + 1}/${claims.length}. Probe-mode is enabled and set to ${probe}.` );
				}
				WikidataPain._batch( offset + 1, claims, action, config, resolve, reject );
			}, function ( msg ) {
				let answer = null;
				do {
					answer = prompt( `${msg !== undefined ? msg + '\n\n' : ''}Action failed for #${offset} ${JSON.stringify( claims[ offset ] )}. Do you want to [c]ancel, [r]etry or [s]kip?` );
					if ( answer !== null ) {
						answer = answer.toLowerCase();
					}
				} while ( !/^[crs]$/.test( answer ) && answer !== null );
				switch ( answer ) {
					case 'c':
						reject();
						break;
					case 'r':
						WikidataPain._batch( offset, claims, action, config, resolve, reject );
						break;
					case 's':
						WikidataPain._batch( offset + 1, claims, action, config, resolve, reject );
						break;
					case null:
						reject();
				}
			} );
		} else {
			if ( WikidataPain.BATCH_ALERT_ON_FINISHED ) {
				alert( 'Batch finished.' );
			}
			resolve();
		}
	}

	/**
	 * @param {string} date 
	 * @return {DissectedTime}
	 */
	static dissectTimestamp( date ) {
		const match = date.match( /(?<time>\+(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})T00:00:00Z)\/(?<precision>\d{1,2})/ );

		if ( match === null ) {
			throw new Error( `Unknown date ${date}` );
		}
		return {
			day: parseInt( match.groups.year ),
			month: parseInt( match.groups.year ),
			precision: parseInt( match.groups.precision ),
			time: match.groups.time,
			year: parseInt( match.groups.year )
		};
	}

	/**
	 * Try to format a English/German date with day-precision as a QuickStatement date.
	 *
	 * @param {string} input
	 * @return {string}
	 */
	static formatDates( input ) {
		// # Precision 11
		// ## Textual
		input = input.replace( /(?<day>[0-3]?\d)\. ?(?<month>(Jan(uary?)?|Feb(ruary?)?|März?|Mar(ch)?|Apr(il)?|Ma[iy]|Jun[ei]?|Jul[iy]?|Aug(ust)?|Sep(tember)?|Okt(ober)?|Nov(ember)?|Dez(ember)?|Dec(ember)?)) (?<year>\d{4})/g, function ( ...d ) {
			const matches = d[ d.length - 1 ];
			return `+${matches.year}-${getMonth( matches.month )}-${matches.day.padStart( 2, '0' )}T00:00:00Z/11`;
		} );

		return input;

		function getMonth( textualMonth ) {
			switch ( textualMonth ) {
				case 'Jan':
				case 'Januar':
				case 'January':
					return '01';
				case 'Feb':
				case 'Februar':
				case 'February':
					return '02';
				case 'Mär':
				case 'März':
				case 'Mar':
				case 'March':
					return '03';
				case 'Apr':
				case 'April':
					return '04';
				case 'Mai':
				case 'May':
					return '05';
				case 'Jun':
				case 'Juni':
				case 'June':
					return '06';
				case 'Jul':
				case 'Juli':
				case 'July':
					return '07';
				case 'Aug':
				case 'August':
					return '08';
				case 'Sep':
				case 'September':
					return '09';
				case 'Okt':
				case 'Oktober':
					return '10';
				case 'Nov':
				case 'November':
					return '11';
				case 'Dez':
				case 'Dezember':
				case 'Dec':
				case 'December':
					return '12';
				default:
					throw new Error( `Unknown month ${textualMonth}` );
			}
		}
	}

	static get( requestBody ) {
		if ( !( 'format' in requestBody ) ) {
			requestBody.format = 'json';
		}
		if ( !( 'maxlag' in requestBody ) ) {
			requestBody.maxlag = WikidataPain.MAXLAG;
		}

		return new Promise( function ( resolve, reject ) {
			$.ajax( {
				data: requestBody,
				method: 'GET',
				url: WikidataPain.ENDPOINT
			} ).done(
				/**
				 * @param {Object} data
				 */
				function ( data ) {
					if ( !Object.prototype.hasOwnProperty.call( data, 'error' ) ) {
						resolve( data );
					} else {
						reject( data );
					}
				}
			).fail( reject );
		} );
	}

	/**
	 * @deprecated
	 * @param {string} entity Wikidata Q-ID.
	 * @param {string} [property] Wikidata P-ID.
	 * @return {Promise<Object>}
	 */
	static getClaimsByEntity( entity, property = null ) {
		return new Promise( function ( resolve, reject ) {
			const apiParams = {
				action: 'wbgetclaims',
				entity: entity
			};
			if ( property !== null ) {
				apiParams.property = property;
			}

			WikidataPain.get( apiParams ).then( function ( data ) {
				if ( data.success === 1 ) {
					resolve( data );
				} else {
					reject( data );
				}
			}, reject );
		} );
	}

	/**
	 * @private
	 * @template T
	 * @param {Config} config
	 * @param {ConfigKey} key
	 * @param {T} [fallback]
	 * @return {(T|string)?}
	 */
	static getConfig( config, key, fallback ) {
		switch ( key ) {
			case WikidataPain.ConfigKey.compoundSummary:
				if ( typeof config.editGroup !== 'string' && typeof config.summary !== 'string' ) {
					return '';
				} else if ( typeof config.editGroup === 'string' && typeof config.summary === 'string' ) {
					return `${config.summary} ([[:toollabs:editgroups/b/CB/${config.editGroup}|details]])`;
				} else if ( typeof config.editGroup === 'string' ) {
					return `([[:toollabs:editgroups/b/CB/${config.editGroup}|details]])`;
				} else {
					return config.summary || fallback;
				}
			default:
				if ( config && config[ key ] !== undefined ) {
					return config[ key ];
				} else {
					return fallback;
				}
		}
	}

	static interceptorPrint() {
		const logItems = WikidataPain.#interceptorLog.map( function ( logItem ) {
			return JSON.stringify( logItem );
		} ).join( ',\n' );
		console.log( `[${logItems}]` );
	}

	static interceptorToggle() {
		if ( WikidataPain.#interceptorOldPost !== null ) {
			unsafeWindow.mw.Api.prototype.post = WikidataPain.#interceptorOldPost;
			WikidataPain.#interceptorOldPost = null;
			GM_notification( 'Interceptor disabled', 'WikidataPain' );
		} else {
			WikidataPain.#interceptorOldPost = unsafeWindow.mw.Api.prototype.post;
			unsafeWindow.mw.Api.prototype.post = WikidataPain.#intercept;
			GM_notification( 'Interceptor enabled', 'WikidataPain' );
		}
	}

	/**
	 * Normalises claim IDs as returned by WDQS.
	 *
	 * @param {string} claimId
	 * @return {string}
	 */
	static normalizeClaimId( claimId ) {
		claimId = claimId.trim();
		if ( /^Q\d+\$[a-z\d]{8}-([a-z\d]{4}-){3}[a-z\d]{12}$/i.test( claimId ) ) {
			return claimId;
		} else {
			const matches = claimId.match( /^(wds:)?(?<qid>Q\d+)-(?<hash>[a-z\d]{8}-([a-z\d]{4}-){3}[a-z\d]{12}$)/i );
			if ( matches !== null ) {
				return `${matches.groups.qid}$${matches.groups.hash}`;
			} else {
				return null;
			}
		}
	}

	static qualifiersSubsetOf( partial, whole, ignoredQualifiers ) {
		if ( !( 'qualifiers' in partial ) ) {
			return true;
		} else if ( 'qualifiers' in partial && !( 'qualifiers' in whole ) ) {
			return false;
		} else {
			const partialQualifiers = partial.qualifiers;
			const wholeQualifiers = whole.qualifiers;
			return Object.keys( partialQualifiers ).every( function ( partialQualifierProp ) {
				return partialQualifierProp in ignoredQualifiers || // If ignored, then skip
					( ( partialQualifierProp in wholeQualifiers ) && partialQualifiers[ partialQualifierProp ].every( function ( partialSnak ) {
						return wholeQualifiers[ partialQualifierProp ].some( function ( wholeSnak ) {
							return partialSnak.snaktype === wholeSnak.snaktype &&
								WikidataPain.DataValue.fromRaw( partialSnak.datavalue ).equals( WikidataPain.DataValue.fromRaw( wholeSnak.datavalue ) );
						} );
					} ) );
			} );
		}
	}

	/**
	 * @return {Promise<string>} CSRF-token.
	 */
	static queryCsrfToken() {
		return new Promise( function ( resolve, reject ) {
			$.ajax( {
				data: {
					action: 'query',
					format: 'json',
					maxlag: WikidataPain.MAXLAG,
					meta: 'tokens',
					type: 'csrf'
				},
				method: 'GET',
				url: WikidataPain.ENDPOINT
			} ).done( function ( data ) {
				if ( !Object.prototype.hasOwnProperty.call( data, 'error' ) ) {
					resolve( data.query.tokens.csrftoken );
				} else {
					reject( data );
				}
			} ).fail( function ( data ) {
				reject( data );
			} );
		} );
	}

	/**
	 * @deprecated
	 * @param {string} claimId Wikidata claim-ID.
	 * @param {string} newValue Wikidata Q-ID of the value to use as new mainsnak.
	 * @param {string} [oldValue] Wikidata Q-ID to replace. If not set any value will be replaced.
	 * @param {string} [summary]
	 * @return {Promise<void>}
	 */
	static replaceMainSnak( claimId, newValue, oldValue, summary ) {
		return new Promise( function ( resolve, reject ) {
			WikidataPain.Statement.fromClaimId( claimId ).getObject().then( function ( properties ) {
				let claimToReplace;

				if ( Object.keys( properties.claims ).length === 1 ) {
					const claims = properties.claims[ Object.keys( properties.claims )[ 0 ] ];
					if ( claims.length === 1 && claims[ 0 ].id.toLowerCase() === claimId.toLowerCase() ) {
						claimToReplace = claims[ 0 ];

						// Check, if enabled
						if ( typeof oldValue === 'string' ) {
							if ( claimToReplace.mainsnak.datavalue.value.id !== oldValue ) {
								reject();
							}
						}

						// Perform replace
						claimToReplace.mainsnak.datavalue.value.id = newValue;
						delete claimToReplace.mainsnak.datavalue.value[ 'numeric-id' ];
					} else {
						reject();
					}
				} else {
					reject();
				}

				WikidataPain.setClaim( JSON.stringify( claimToReplace ), summary ).then( resolve, reject );
			} );
		} );
	}

	static setClaim( claim, summary ) {
		return new Promise( function ( resolve, reject ) {
			WikidataPain.queryCsrfToken().then( function ( token ) {
				$.ajax( {
					data: {
						action: 'wbsetclaim',
						claim: ( typeof claim === 'string' ? claim : JSON.stringify( claim ) ),
						format: 'json',
						maxlag: WikidataPain.MAXLAG,
						summary: summary,
						token: token
					},
					method: 'POST',
					url: WikidataPain.ENDPOINT
				} ).done( function ( data ) {
					if ( !Object.prototype.hasOwnProperty.call( data, 'error' ) && data.success === 1 ) {
						resolve( data );
					} else {
						reject( data );
					}
				} ).fail( function ( data ) {
					reject( data );
				} );
			}, reject );
		} );
	}

	static setClaimValue( claimGuid, value, snaktype, summary ) {
		return new Promise( function ( resolve, reject ) {
			WikidataPain.queryCsrfToken().then( function ( token ) {
				$.ajax( {
					data: {
						action: 'wbsetclaimvalue',
						claim: claimGuid,
						format: 'json',
						maxlag: WikidataPain.MAXLAG,
						snaktype: snaktype,
						summary: summary,
						token: token,
						value: ( typeof value === 'string' ? value : JSON.stringify( value ) )
					},
					method: 'POST',
					url: WikidataPain.ENDPOINT
				} ).done( function ( data ) {
					if ( !Object.prototype.hasOwnProperty.call( data, 'error' ) && data.success === 1 ) {
						resolve( data );
					} else {
						reject( data );
					}
				} ).fail( reject );
			}, reject );
		} );
	}

	static #intercept( parameters ) {
		if ( parameters && parameters.action !== undefined ) {
			switch ( parameters.action ) {
				case 'wbsetclaim':
					console.log( JSON.stringify( 'wbsetclaim' ), JSON.parse( parameters.claim ) );

					const outClaim = JSON.parse( parameters.claim );
					delete outClaim.id;

					WikidataPain.#interceptorLog.push( {
						action: 'wbsetclaim',
						config: {
							claimId: 'complete_subset_or_new',
							entity: 'current'
						},
						claim: outClaim
					} );

					break;
			}

			return unsafeWindow.mw.loader.using( 'oojs-ui-core' ).then( function () {
				return unsafeWindow.OO.ui.confirm( 'Action has been intercepted. Do you want to save it to Wikidata?', {
					size: 'medium'
				} ).done( function ( confirmed ) {
					if ( confirmed ) {
						return WikidataPain.#interceptorOldPost.apply( this, arguments );
					}
				} );
			} );
		} else {
			return WikidataPain.#interceptorOldPost.apply( this, arguments );
		}
	}
}

/**
 * @class
 */
WikidataPain.DataValue = class {
	/**
	 * @type {string}
	 */
	type = null;

	/**
	 * @type {Object}
	 */
	value = null;

	/**
	 * @hideconstructor
	 * @param {string} type
	 * @param {Object} value
	 */
	constructor( type, value ) {
		this.type = type;
		this.value = value;
	}

	/**
	 * @param {string} entityId
	 * @return {WikidataPain.DataValue}
	 */
	static fromEntity( entityId ) {
		return new WikidataPain.DataValue( 'wikibase-entityid', {
			id: entityId
		} );
	}

	/**
	 * @param {Object} raw
	 * @return {WikidataPain.DataValue}
	 */
	static fromRaw( raw ) {
		return new WikidataPain.DataValue( raw.type, raw.value );
	}

	/**
	 * @param {string} string
	 * @return {WikidataPain.DataValue}
	 */
	static fromString( string ) {
		return new WikidataPain.DataValue( 'string', string );
	}

	/**
	 * @param {number} year
	 * @param {number} [month]
	 * @param {number} [day]
	 * @return {WikidataPain.DataValue}
	 */
	static fromTime( year, month = null, day = null ) {
		let precision;
		if ( month === null ) {
			precision = WikidataPain.Precision.Year;
		} else if ( day === null ) {
			precision = WikidataPain.Precision.Month;
		} else {
			precision = WikidataPain.Precision.Day;
		}

		return new WikidataPain.DataValue( 'time', {
			after: 0,
			before: 0,
			calendarmodel: 'http://www.wikidata.org/entity/Q1985727',
			precision: precision,
			time: `+${year}-${precision >= WikidataPain.Precision.Month ? month.toString().padStart( 2, '0' ) : '00'}-${precision >= WikidataPain.Precision.Day ? day.toString().padStart( 2, '0' ) : '00'}T00:00:00Z`,
			timezone: 0
		} );
	}

	/**
	 * Convert a QuickStatement-date-string to a time-object.
	 *
	 * @param {string} date Timestamp in QuickStatement-format.
	 * @return {WikidataPain.DataValue}
	 */
	static fromTimestamp( date ) {
		const components = WikidataPain.dissectTimestamp( date );

		return new WikidataPain.DataValue( 'time', {
			after: 0,
			before: 0,
			calendarmodel: 'http://www.wikidata.org/entity/Q1985727',
			precision: components.precision,
			time: components.time,
			timezone: 0
		} );
	}

	/**
	 * Compares the precision of this DataValue with another DataValue's precision.
	 *
	 * @param {WikidataPain.DataValue} other DataValue to compare to.
	 * @return {WikidataPain.PrecisionComparison}
	 */
	comparePrecision( other ) {
		if ( this.type !== other.type ) {
			return WikidataPain.PrecisionComparison.IncomparableValue;
		}
		switch ( this.type ) {
			case 'monolingualtext':
				if ( this.value.language === other.value.language && this.value.text === other.value.text ) {
					return WikidataPain.PrecisionComparison.EqualValue;
				} else {
					return WikidataPain.PrecisionComparison.IncomparableValue;
				}
			case 'string':
				if ( this.value === other.value ) {
					return WikidataPain.PrecisionComparison.EqualValue;
				} else {
					return WikidataPain.PrecisionComparison.IncomparableValue;
				}
			case 'time':
				if ( this.value.after !== other.value.after || 
					this.value.before !== other.value.before ||
					this.value.calendarmodel !== other.value.calendarmodel ||
					this.value.timezone === other.value.timezone ) {
						return WikidataPain.PrecisionComparison.IncomparableValue;
				}

				const overlap = timesOverlap( WikidataPain.dissectTimestamp( this.value.time ), WikidataPain.dissectTimestamp( other.value.time ), Math.min( this.value.precision, other.value.precision ) );

				if ( !overlap ) {
					return WikidataPain.PrecisionComparison.IncomparableValue;
				} else if ( this.value.precision === other.value.precision ) {
					return WikidataPain.PrecisionComparison.EqualValue;
				} else if ( this.value.precision < other.value.precision ) {
					return WikidataPain.PrecisionComparison.LessPrecise;
				} else {
					return WikidataPain.PrecisionComparison.GreaterPrecision;
				}
			case 'wikibase-entityid':
				if ( this.value.id === other.value.id ) {
						return WikidataPain.PrecisionComparison.EqualValue;
				} else {
					return WikidataPain.PrecisionComparison.IncomparableValue;
				}
			default:
				if ( this.value === other.value ) {
						return WikidataPain.PrecisionComparison.EqualValue;
				} else {
					return WikidataPain.PrecisionComparison.IncomparableValue;
				}
		}


		/**
		 * 
		 * @param {DissectedTime} lesser 
		 * @param {DissectedTime} greater 
		 * @param {WikidataPain.Precision} comparisonPrecision
		 * @return {boolean}
		 */
		function timesOverlap( lesser, greater, comparisonPrecision ) {
			if ( lesser.year !== greater.year ) {
				return false;
			}
			if ( comparisonPrecision >= WikidataPain.Precision.Month && lesser.month !== greater.month ) {
				return false;
			}
			if ( comparisonPrecision >= WikidataPain.Precision.Day && lesser.day !== greater.day ) {
				return false;
			}
			return true;
		}
	}

	/**
	 * Compares this DataValue with another DataValue.
	 *
	 * @param {WikidataPain.DataValue} other DataValue to compare to.
	 * @return {boolean} `true` if equal, otherwise `false`.
	 */
	equals( other ) {
		return this.comparePrecision( other ) == WikidataPain.PrecisionComparison.EqualValue
	}

	/**
	 * @return {Object}
	 */
	toJSON() {
		return {
			type: this.type,
			value: this.value
		};
	}

	/**
	 * @return {string}
	 */
	toString() {
		return this.toJSON().toString();
	}
};

/**
 * @enum {number}
 */
 WikidataPain.Precision = Object.freeze( {
	Year: 9,
	Month: 10,
	Day: 11
} );

/**
 * @enum {string}
 */
 WikidataPain.PrecisionComparison = Object.freeze( {
	EqualValue: 'equalValue',
	GreaterPrecision: 'greaterPrecision',
	IncomparableValue: 'incomparableValue',
	LessPrecise: 'lessPrecise'
} );

/**
 * @enum {string}
 */
WikidataPain.Rank = Object.freeze( {
	Deprecated: 'deprecated',
	Normal: 'normal',
	Preferred: 'preferred'
} );

/**
 * @class
 */
WikidataPain.Reference = class {
	/**
	 * @type {string}
	 */
	hash = null;

	/**
	 * @type {Object.<string, Array<WikidataPain.Snak>>}
	 */
	snaks = null;

	/**
	 * @type {Array<string>}
	 */
	snaksOrder = null;

	/**
	 * @hideconstructor
	 */
	constructor() {

	}

	/**
	 * @param {Object} raw
	 * @return {WikidataPain.Reference}
	 */
	static fromRaw( raw ) {
		const statement = new WikidataPain.Reference();
		statement.hash = raw.hash;
		statement.snaks = raw.snaks;
		statement.snaksOrder = raw[ 'snaks-order' ];

		return statement;
	}

	/**
	 * @param {WikidataPain.Reference} other
	 * @return {boolean}
	 */
	static equals( other ) {
		// @TODO
		throw new Error( 'Not implemented.' );

		/*
		if ( !( 'references' in l ) && !( 'references' in r ) ) {
			return true;
		} else if ( ( 'references' in l || 'references' in r ) && !( 'references' in wdClaim && 'references' in command.claim ) ) {
			return false;
		} else {
			const referencesMatch = WikidataPain.equalReferences( command, claims[ property ][ 0 ] );
			// @TODO
		}
		*/
	}

	/**
	 * @return {Object}
	 */
	toJSON() {
		return {
			hash: this.hash,
			snaks: this.snaks,
			'snaks-order': this.snaksOrder
		};
	}
};

/**
 * @class
 */
WikidataPain.Snak = class {
	/**
	 * @type {WikidataPain.DataValue}
	 */
	datavalue = null;

	/**
	 * @type {string}
	 */
	property = null;

	/**
	 * @type {WikidataPain.Snak.Type}
	 */
	snaktype = null;

	/**
	 * @hideconstructor
	 * @param {WikidataPain.Snak.Type} snaktype
	 * @param {Object} property
	 * @param {WikidataPain.DataValue} [datavalue]
	 */
	constructor( snaktype, property, datavalue = null ) {
		this.datavalue = datavalue;
		this.property = property;
		this.snaktype = snaktype;
	}

	/**
	 * @param {string} property
	 * @param {WikidataPain.DataValue} dataValue
	 * @return {WikidataPain.Snak}
	 */
	static fromDataValue( property, dataValue ) {
		return new WikidataPain.Snak( WikidataPain.Snak.Type.Value, property, dataValue );
	}

	/**
	 * @param {string} property
	 * @param {string} entityId
	 * @return {WikidataPain.Snak}
	 */
	static fromEntity( property, entityId ) {
		return WikidataPain.Snak.fromDataValue( property, WikidataPain.DataValue.fromEntity( entityId ) );
	}

	/**
	 * @param {Object} raw
	 * @return {WikidataPain.Snak}
	 */
	static fromRaw( raw ) {
		return new WikidataPain.Snak( raw.snaktype === undefined ? null : raw.snaktype, raw.property === undefined ? null : raw.property, raw.datavalue === undefined ? null : WikidataPain.DataValue.fromRaw( raw.datavalue ) );
	}

	/**
	 * @param {string} property
	 * @param {string} string
	 * @return {WikidataPain.Snak}
	 */
	static fromString( property, string ) {
		return WikidataPain.Snak.fromDataValue( property, WikidataPain.DataValue.fromString( string ) );
	}

	/**
	 * @param {string} property
	 * @param {number} year
	 * @param {number} [month]
	 * @param {number} [day]
	 * @return {WikidataPain.Snak}
	 */
	static fromTime( property, year, month = null, day = null ) {
		return WikidataPain.Snak.fromDataValue( property, WikidataPain.DataValue.fromTime( year, month, day ) );
	}

	/**
	 * Compares the precision of this Snak with another Snak's precision.
	 *
	 * @param {WikidataPain.Snak} other Snak to compare to.
	 * @return {WikidataPain.PrecisionComparison}
	 */
	comparePrecision( other ) {
		if ( this.property !== other.property ) {
			return WikidataPain.PrecisionComparison.IncomparableValue;
		}

		// Snaktypes must be same or one `somevalue`
		if ( this.snaktype !== other.snaktype ) {
			if ( this.snaktype === WikidataPain.Snak.Type.Somevalue ) {
				return WikidataPain.PrecisionComparison.LessPrecise;
			} else if ( other.snaktype === WikidataPain.Snak.Type.Somevalue ) {
				return WikidataPain.PrecisionComparison.GreaterPrecision;
			} else {
				return WikidataPain.PrecisionComparison.IncomparableValue;
			}
		}

		return this.datavalue.comparePrecision( other.datavalue );
	}

	/**
	 * Compares this Snak with another Snak.
	 *
	 * @param {WikidataPain.Snak} other Snak to compare to.
	 * @return {boolean} `true` if equal, otherwise `false`.
	 */
	equals( other ) {
		return this.comparePrecision( other ) === WikidataPain.PrecisionComparison.EqualValue;
	}

	/**
	 * @return {Object}
	 */
	toJSON() {
		const output = {};

		[ 'datavalue', 'property', 'snaktype' ].forEach( function ( field ) {
			if ( this[ field ] !== null ) {
				output[ field ] = this[ field ];
			}
		} );

		return output;
	}

	/**
	 * @return {string}
	 */
	toString() {
		return this.toJSON().toString();
	}
};

/**
 * @enum {string}
 */
 WikidataPain.Snak.Type = Object.freeze( {
	NoValue: 'novalue',
	Somevalue: 'somevalue',
	Value: 'value'
} );

/**
 * @class
 */
WikidataPain.Statement = class {
	/**
	 * @type {boolean}
	 */
	#loaded = false;

	/**
	 * @type {boolean}
	 */
	#referencesLoaded = false;

	/**
	 * @type {string}
	 */
	id = null;

	/**
	 * @type {WikidataPain.Snak}
	 */
	mainsnak = null;

	/**
	 * @type {string}
	 */
	rank = null;

	/**
	 * @type {string}
	 */
	type = null;

	/**
	 * @type {Object.<string, Array<WikidataPain.Snak>>}
	 */
	qualifiers = null;

	/**
	 * @type {Array<string>}
	 */
	qualifiersOrder = null;

	/**
	 * @type {Array<Object>}
	 */
	references = null;

	/**
	 * @hideconstructor
	 * @param {string} id
	 * @param {boolean} loaded
	 */
	constructor( id, loaded ) {
		this.id = id;
		this.#loaded = loaded;
	}

	/**
	 * Collapses a dictionary of properties and statements to a simple list of statements.
	 *
	 * @param {Object.<string, Array<WikidataPain.Statement>>} dictionary
	 * @return {Array<WikidataPain.Statement>}
	 */
	static collapsedApiDictionary( dictionary ) {
		const output = [];

		Object.keys( dictionary ).forEach( function ( key ) {
			output.push( ...dictionary[ key ] );
		} );

		return output;
	}

	/**
	 * Function used to transform a value. First argument is the value, second argument is the type of datavalue.
	 *
	 * @typedef {function(Object, string): (Object|WikidataPain.Snak.Type)} ValueTransformer
	 */

	/**
	 * @param {Array<WikidataPain.Statement>|Object.<string, Array<WikidataPain.Statement>>} claims
	 * @param {function(WikidataPain.Statement): Promise<boolean>} predicate
	 * @return {Promise<Array<WikidataPain.Statement>>}
	 */
	static filterCollapsed( claims, predicate ) {
		/**
		 * @type {Array<WikidataPain.Statement>}
		 */
		let collapsedClaims = null;
		if ( !Array.isArray( claims ) ) {
			collapsedClaims = WikidataPain.Statement.collapsedApiDictionary(
				/** @type {Object.<string, Array<WikidataPain.Statement>>} */( claims )
			);
		} else {
			collapsedClaims = claims;
		}

		return WikidataPain.Util.filterAsync( collapsedClaims, function ( value ) {
			return predicate( value );
		} );
	}

	/**
	 * @param {Object.<string, Array<WikidataPain.Statement>>} claims
	 * @param {string} property
	 * @param {any} entity
	 * @param {Array<string>} ignoredQualifierProperties
	 * @return {Promise<Array<any>>}
	 */
	static filterStatementsByPropertiesSubset( claims, property, entity, ignoredQualifierProperties = null ) {
		return new Promise( function ( resolve ) {
			if ( Object.keys( claims ).length === 0 ) {
				resolve( null );
			} else {
				WikidataPain.Util.filterAsync( claims[ property ], function ( claim ) {
					return new Promise( function ( resolveFilter ) {
						claim.getMainsnak().then( function ( mainsnak ) {
							// Compare mainsnak
							if ( mainsnak.equals( entity.mainsnak ) ) {
								resolveFilter( false );
								return;
							}

							// Compare qualifiers
							if ( !WikidataPain.qualifiersSubsetOf( claim, entity, ignoredQualifierProperties ) ) {
								return false;
							}

							// Compare references
							// TODO

							return true;
						} );
					} );
				} );
			}
		} );
	}

	/**
	 * Returns an instance of {@link WikidataPain.Statement} by its Claim-ID.
	 * Implicitly normalizes claimId using {@link WikidataPain.normalizeClaimId}.
	 *
	 * @param {string} claimId Wikidata Claim-ID.
	 * @return {WikidataPain.Statement}
	 */
	static fromClaimId( claimId ) {
		const normalizedClaimId = WikidataPain.normalizeClaimId( claimId );

		return new WikidataPain.Statement( normalizedClaimId, false );
	}

	/**
	 * @param {string} entity Wikidata Q-ID.
	 * @param {string} [property] Wikidata P-ID.
	 * @return {Promise<Object.<string, Array<WikidataPain.Statement>>>}
	 */
	static fromEntity( entity, property = null ) {
		return new Promise( function ( resolve, reject ) {
			const apiParams = {
				action: 'wbgetclaims',
				entity: entity
			};
			if ( property !== null ) {
				apiParams.property = property;
			}

			WikidataPain.get( apiParams ).then( function ( data ) {
				/**
				 * @type {Object.<string, Array<WikidataPain.Statement>>}
				 */
				const result = {};

				Object.keys( data.claims ).forEach( function ( keyProperty ) {
					result[ keyProperty ] = data.claims[ keyProperty ].map( function ( claim ) {
						return WikidataPain.Statement.fromRaw( claim );
					} );
				} );

				resolve( result );
			}, reject );
		} );
	}

	/**
	 * @param {string} entity Wikidata Q-ID.
	 * @param {string} property Wikidata P-ID.
	 * @return {Promise<Array<WikidataPain.Statement>>}
	 */
	static fromEntityCollapsed( entity, property ) {
		return new Promise( function ( resolve, reject ) {
			WikidataPain.Statement.fromEntity( entity, property ).then( function ( claims ) {
				resolve( WikidataPain.Statement.collapsedApiDictionary( claims ) );
			}, reject );
		} );
	}

	/**
	 * @param {Object} raw
	 * @param {boolean} [referencesLoaded] Set references as loaded.
	 * @return {WikidataPain.Statement}
	 */
	static fromRaw( raw, referencesLoaded = false ) {
		const statement = new WikidataPain.Statement( raw.id, true );
		statement.mainsnak = raw.mainsnak === null || raw.mainsnak === undefined ? null : WikidataPain.Snak.fromRaw( raw.mainsnak );
		statement.rank = WikidataPain.Util.undefinedToNull( raw.rank );
		statement.type = WikidataPain.Util.undefinedToNull( raw.type );

		let qualifiers = null;
		if ( raw.qualifiers !== null && raw.qualifiers !== undefined ) {
			qualifiers = {};

			Object.keys( raw.qualifiers ).forEach( function ( property ) {
				qualifiers[ property ] = raw.qualifiers[ property ].map( function ( qualifier ) {
					return WikidataPain.Snak.fromRaw( qualifier );
				} );
			} );
		}

		statement.qualifiers = qualifiers;
		statement.qualifiersOrder = WikidataPain.Util.undefinedToNull( raw[ 'qualifiers-order' ] );
		statement.references = WikidataPain.Util.undefinedToNull( raw.references );
		statement.#referencesLoaded = referencesLoaded;

		return statement;
	}

	/**
	 * Is committed instantly and may not be chained.
	 *
	 * @param {WikidataPain.Reference} reference
	 * @param {Object?} [options]
	 * @return {Promise<void>}
	 */
	addReferenceInstant( reference, options ) {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			WikidataPain.queryCsrfToken().then( function ( token ) {
				$.ajax( {
					data: WikidataPain.Util.commonParameters( {
						action: 'wbsetreference',
						snaks: JSON.stringify( reference.snaks ),
						'snaks-order': JSON.stringify( reference[ 'snaks-order' ] ),
						statement: that.id,
						token: token
					}, options ),
					method: 'POST',
					url: WikidataPain.ENDPOINT
				} ).done( function ( data ) {
					if ( !Object.prototype.hasOwnProperty.call( data, 'error' ) && data.success === 1 ) {
						resolve( data );
					} else {
						reject( data );
					}
				} ).fail( reject );
			}, reject );
		} );
	}

	/**
	 * @return {Promise<void>}
	 */
	commit() {
		return new Promise( function ( resolve, reject ) {
			// TODO
		} );
	}

	/**
	 * Deletes the statement using `wbremoveclaims`.
	 * Is committed instantly and may not be chained.
	 *
	 * @param {Object?} [options]
	 * @return {Promise<void>}
	 */
	deleteInstant( options ) {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			WikidataPain.queryCsrfToken().then( function ( token ) {
				$.ajax( {
					data: WikidataPain.Util.commonParameters( {
						action: 'wbremoveclaims',
						claim: that.id,
						token: token
					}, options ),
					method: 'POST',
					url: WikidataPain.ENDPOINT
				} ).done( function ( data ) {
					if ( !Object.prototype.hasOwnProperty.call( data, 'error' ) && data.success === 1 ) {
						resolve( data );
					} else {
						reject( data );
					}
				} ).fail( reject );
			}, reject );
		} );
	}

	/**
	 * @param {Array<function(WikidataPain.Statement): Promise<boolean>>} predicates
	 * @return {Promise<boolean>}
	 */
	filterAnd( predicates ) {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			Promise.all( predicates.map( function ( predicate ) {
				return predicate( that );
			} ) ).then( function ( evaluatedPredicates ) {
				resolve( evaluatedPredicates.every( function ( evaluatedPredicate ) {
					return evaluatedPredicate;
				} ) );
			}, reject );
		} );
	}

	/**
	 * @param {string} property
	 * @param {function(Array<WikidataPain.Snak>): Promise<boolean>} predicate
	 * @return {Promise<boolean>}
	 */
	filterByQualifier( property, predicate ) {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			that.#assertLoaded().then( function () {
				if ( that.qualifiers === null || that.qualifiers[ property ] === undefined ) {
					resolve( null );
				} else {
					predicate( that.qualifiers[ property ] ).then( resolve, reject );
				}
			}, reject );
		} );
	}

	/**
	 * @param {function(Object.<string, Array<WikidataPain.Snak>>): Promise<boolean>} predicate
	 * @return {Promise<boolean>}
	 */
	filterByQualifiers( predicate ) {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			this.#assertLoaded().then( function () {
				predicate( that.qualifiers ).then( resolve, reject );
			}, reject );
		} );
	}

	/**
	 * @param {function(WikidataPain.DataValue): Promise<boolean>} predicate
	 * @return {Promise<boolean>}
	 */
	filterByMainsnakDataValue( predicate ) {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			that.getMainsnak().then( function ( mainsnak ) {
				predicate( mainsnak.datavalue ).then( resolve, reject );
			}, reject );
		} );
	}

	/**
	 * @return {Promise<WikidataPain.Snak>}
	 */
	getMainsnak() {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			that.#assertLoaded( false ).then( function () {
				resolve( WikidataPain.Snak.fromRaw( that.mainsnak ) );
			}, reject );
		} );
	}

	/**
	 * @param {boolean} [requestReferences] Whether to load references
	 * @return {Promise<Object>}
	 */
	getObject( requestReferences ) {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			that.#assertLoaded( requestReferences ).then( function () {
				resolve( {
					id: that.id,
					mainsnak: that.mainsnak,
					qualifiers: that.qualifiers,
					'qualifers-order': that.qualifiersOrder,
					references: that.references
				} );
			}, reject );
		} );
	}

	/**
	 * @return {Promise<WikidataPain.Reference[]>}
	 */
	getReferences() {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			that.#assertLoaded( true ).then( function () {
				if ( that.references === undefined ) {
					resolve( [] );
				} else {
					resolve( that.references.map( ( reference ) => WikidataPain.Reference.fromRaw( reference ) ) );
				}
			}, reject );
		} );
	}

	/**
	 * Creates a new statement with a different property based on this one and deletes this statement.
	 * Is committed instantly and may not be chained.
	 *
	 * @param {string} targetProperty New property to move the claim to.
	 * @param {ValueTransformer?} [valueTransformer] Function to apply to the claim's value which's result will be used as value for the newly created statement's claim.
	 * @param {Object?} [options]
	 * @return {Promise<WikidataPain.Statement>}
	 */
	moveToPropertyInstant( targetProperty, valueTransformer, options ) {
		return new Promise( function ( resolve, reject ) {
			// TODO
		} );
	}

	/**
	 * 
	 * @param {string} property
	 * @param {WikidataPain.Snak} newQualifier
	 * @param {Object} config
	 * @param {('delete'|'fail')} [config.onAdditionalQualifierValues]
	 * @return {Promise<WikidataPain.Statement>}
	 */
	qualifierAddOrRefine( property, newQualifier, { onAdditionalQualifierValues } = { onAdditionalQualifierValues: null } ) {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			that.#assertLoaded().then( function () {
				const qualifierSnaks = WikidataPain.Util.undefinedToNull( that.qualifiers[ property ] );

				// Check for additional qualifiers. If there are no qualifier snaks yet, skip.
				if ( onAdditionalQualifierValues !== null && qualifierSnaks !== null && qualifierSnaks.length > 0 ) {
					const incompatibleQualifiers = qualifierSnaks.filter( function ( qualifier ) {
						return [ WikidataPain.PrecisionComparison.IncomparableValue, WikidataPain.PrecisionComparison.GreaterPrecision ].includes( qualifier.comparePrecision( newQualifier ) );
					} );

					if ( incompatibleQualifiers.length > 0 ) {
						switch ( onAdditionalQualifierValues ) {
							case 'delete':
								throw new Error( 'Not implemented' );
							case 'fail':
								reject();
								return;
							default:
								throw new Error();
						}
					}
				}

				if ( qualifierSnaks === null || qualifierSnaks.length === 0 ) {
					// Add: If there are no qualifier snaks yet, just add new qualifier.
					that.qualifiers[ property ] = [ newQualifier ];
					resolve( that );
				} else {
					// Check, if qualifier snak already exists
					const equalQualifiers = qualifierSnaks.filter( function ( qualifier ) {
						qualifier.comparePrecision( newQualifier ) === WikidataPain.PrecisionComparison.EqualValue;
					} );
	
					if ( equalQualifiers.length > 0 ) {
						// Exists already
						resolve( that );
					} else {
						// Check, if there are refineable qualifier snaks
						const refinableQualifiers = qualifierSnaks.filter( function ( qualifier ) {
							qualifier.comparePrecision( newQualifier ) === WikidataPain.PrecisionComparison.LessPrecise;
						} );
	
						if ( refinableQualifiers.length > 0 ) {
							// Refine qualifiers
							if ( refinableQualifiers.length > 1 ) {
								throw new Error( 'More than one refinable qualifier. This case has not been considered in implementation yet.' );
							} else {
								qualifierSnaks.splice( qualifierSnaks.indexOf( refinableQualifiers[ 0 ] ), 1 );
								qualifierSnaks.push( newQualifier );
								resolve( that );
							}
						} else {
							// Add new qualifier
							that.qualifiers[ property ].push( newQualifier );
							resolve( that );
						}
					}
				}
			}, reject );
		} );
	}

	/**
	 * Sets this statement's value.
	 * Must be committed by calling {@link WikidataPain.Statement#commit}.
	 *
	 * @param {any} newValue New value
	 * @return {Promise<WikidataPain.Statement>} This object for chaining.
	 */
	setValue( newValue ) {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			that.#assertLoaded().then( function () {
				that.mainsnak.datavalue.value = newValue;
			}, reject );
		} );
	}

	/**
	 * Sets this statement's value using `wbsetclaimvalue`.
	 * Is committed instantly and may not be chained.
	 *
	 * @param {any} newValue
	 * @param {WikidataPain.Snak.Type?} [snaktype]
	 * @param {Object?} [options]
	 * @return {Promise<void>}
	 */
	setValueInstant( newValue, snaktype, options ) {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			WikidataPain.queryCsrfToken().then( function ( token ) {
				let requestData;
				if ( snaktype === null || snaktype === undefined || snaktype === WikidataPain.Snak.Type.Value ) {
					requestData = {
						action: 'wbsetclaimvalue',
						claim: that.id,
						snaktype: WikidataPain.Snak.Type.Value,
						token: token,
						value: ( typeof newValue === 'string' ? newValue : JSON.stringify( newValue ) )
					};
				} else {
					requestData = {
						action: 'wbsetclaimvalue',
						claim: that.id,
						snaktype: snaktype,
						token: token
					};
				}

				$.ajax( {
					data: WikidataPain.Util.commonParameters( requestData, options ),
					method: 'POST',
					url: WikidataPain.ENDPOINT
				} ).done( function ( data ) {
					if ( !Object.prototype.hasOwnProperty.call( data, 'error' ) && data.success === 1 ) {
						resolve( data );
					} else {
						reject( data );
					}
				} ).fail( reject );
			}, reject );
		} );
	}

	/**
	 * Applies `valueTransformer` to the statement's claim's value and sets it's output as the claim's new value.
	 * Must be committed by calling {@link WikidataPain.Statement.commit}.
	 *
	 * @param {ValueTransformer} valueTransformer Function to apply to the claim's value which's result with replace the claim's current value.
	 * @return {Promise<WikidataPain.Statement>} This object for chaining.
	 */
	transformValue( valueTransformer ) {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			that.#assertLoaded().then( function () {
				const newValue = valueTransformer( that.mainsnak.datavalue.value, that.mainsnak.snaktype );
				if ( newValue !== null ) {
					that.setValue( newValue );
				}
				resolve( that );
			}, reject );
		} );
	}

	/**
	 * Applies `valueTransformer` to the statement's claim's value and sets it's output as the claim's new value by using `wbsetclaimvalue`.
	 * Is committed instantly and may not be chained.
	 *
	 * @param {ValueTransformer} valueTransformer
	 * @param {Object?} [options]
	 * @return {Promise<void>}
	 */
	transformValueInstant( valueTransformer, options ) {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			that.#assertLoaded().then( function () {
				const newValue = valueTransformer( that.mainsnak.datavalue.value, that.mainsnak.snaktype );

				if ( newValue !== null ) {
					that.setValueInstant( newValue, null, options ).then( resolve, reject );
				} else {
					resolve();
				}
			}, reject );
		} );
	}

	#assertLoaded( requestReferences ) {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			if ( that.#loaded && ( !requestReferences || that.#referencesLoaded ) ) {
				resolve();
			} else {
				that.#loadRaw( requestReferences ).then( function ( raw ) {
					that.mainsnak = WikidataPain.Util.undefinedToNull( raw.mainsnak );
					that.rank = WikidataPain.Util.undefinedToNull( raw.rank );
					that.type = WikidataPain.Util.undefinedToNull( raw.type );
					that.qualifiers = WikidataPain.Util.undefinedToNull( raw.qualifiers );
					that.qualifiersOrder = WikidataPain.Util.undefinedToNull( raw[ 'qualifiers-order' ] );
					that.references = WikidataPain.Util.undefinedToNull( raw.references );
					that.#referencesLoaded = requestReferences;
					resolve();
				}, reject );
			}
		} );
	}

	#loadRaw( requestReferences ) {
		const that = this;

		return new Promise( function ( resolve, reject ) {
			const props = [];
			if ( requestReferences === true ) {
				props.push( 'references' );
			}

			WikidataPain.get( {
				action: 'wbgetclaims',
				claim: that.id,
				props: props.join( '|' )
			} ).then( function ( data ) {
				if ( Object.keys( data.claims ).length > 0 ) {
					console.assert( Object.keys( data.claims ).length === 1 );

					const statementProperty = Object.keys( data.claims )[ 0 ];

					const propertyStatements = data.claims[ statementProperty ];
					console.assert( propertyStatements.length === 1 );

					resolve( propertyStatements[ 0 ] );
				} else {
					reject( data );
				}
			}, reject );
		} );
	}
};

/**
 * @class
 */
WikidataPain.Util = class {
	/**
	 * @param {Object} parameters
	 * @param {Object?} options
	 * @return {Object}
	 */
	static commonParameters( parameters, { editGroup, summary } = {} ) {
		// Format
		if ( parameters.format === undefined ) {
			parameters.format = 'json';
		}

		// MaxLag
		if ( parameters.maxlag === undefined ) {
			parameters.maxlag = WikidataPain.MAXLAG;
		}

		// Summary
		let compoundSummary;
		if ( typeof editGroup !== 'string' && typeof summary !== 'string' ) {
			compoundSummary = null;
		} else if ( typeof editGroup === 'string' && typeof summary === 'string' ) {
			compoundSummary = `${summary} ([[:toollabs:editgroups/b/CB/${editGroup}|details]])`;
		} else if ( typeof editGroup === 'string' ) {
			compoundSummary = `([[:toollabs:editgroups/b/CB/${editGroup}|details]])`;
		} else {
			compoundSummary = summary;
		}

		if ( compoundSummary !== null ) {
			parameters.summary = compoundSummary;
		}

		return parameters;
	}

	/**
	 * Get the entity-ID of the entity that's currently open.
	 *
	 * @return {string?}
	 */
	static currentEntityId() {
		const entityIdMatch = document.querySelector( '.wikibase-title-id' ).textContent.match( /\((.+)\)/ );
		if ( entityIdMatch !== null ) {
			return entityIdMatch[ 1 ];
		} else {
			return null;
		}
	}

	/**
	 * @param {string} claimId Wikidata claim-ID.
	 * @return {string} Wikidata Q-ID which the claimId belongs to.
	 */
	static entityIdFromClaimId( claimId ) {
		const matches = claimId.match( /^(?<qid>Q\d+)\$[a-z\d]{8}-([a-z\d]{4}-){3}[a-z\d]{12}$/i );
		if ( matches !== null ) {
			return matches.groups.qid;
		} else {
			return null;
		}
	}

	/**
	 * @template T
	 * @param {Array<T>} array
	 * @param {function(T, number, Array<T>): Promise<boolean>} callbackFn
	 * @return {Promise<Array<T>>}
	 */
	static filterAsync( array, callbackFn ) {
		return new Promise( function ( resolve, reject ) {
			Promise.all( array.map( callbackFn ) ).then( function ( predicatedArray ) {
				resolve( array.filter( function ( value, index ) {
					return predicatedArray[ index ];
				} ) );
			}, reject );
		} );
	}

	/**
	 * @param {string} entity Wikidata claim-ID.
	 * @return {string}
	 */
	static randomClaimId( entity ) {
		return `${entity}$${WikidataPain.Util.randomUuidV4()}`;
	}

	/**
	 * Returns a random ID which can be used from EditGroups.
	 *
	 * @return {string}
	 */
	static randomEditGroup() {
		return Math.floor( Math.random() * Math.pow( 2, 48 ) ).toString( 16 );
	}

	/**
	 * Random UUID which can be used as claim-id.
	 * Stolen from StackOverflow (of course): https://stackoverflow.com/a/2117523
	 *
	 * @return {string}
	 */
	static randomUuidV4() {
		return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( /[xy]/g, function ( c ) {
			const r = Math.random() * 16 | 0,
				v = c === 'x' ? r : ( r & 0x3 | 0x8 );
			return v.toString( 16 ).toUpperCase();
		} );
	}

	/**
	 * @template T
	 * @param {T} value
	 * @return {T}
	 */
	static undefinedToNull( value ) {
		return value === undefined ? null : value;
	}
};

GM_registerMenuCommand( 'Adhoc-mode', WikidataPain.adhoc );
GM_registerMenuCommand( 'Toggle interceptor', WikidataPain.interceptorToggle );
unsafeWindow.WikidataPain = WikidataPain;