WikidataPain.user.js

// ==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      $VERSION
// ==/UserScript==

/**
 * Main class
 *
 * @hideconstructor
 */
class WikidataPain {
	/**
	 * 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;

	static PROPERTY_REASON_FOR_DEPRECATION = 'P2241';

	static PROPERTY_REASON_FOR_PREFERENCE = 'P7452';

	static RSOLVER = 'https://muekoeff.gitlab.io/rsolver/';

	static SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql';

	/**
	 * 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;
	static #outputPipeHandlerRegistered = false;
	static #outputPipeReady = false;
	/** @type {Promise<void>} */
	static #taskWorker = null;
	/**
	 * @type {Array<{
	 *  action: function(): Promise<void?>,
	 *  resolve: Function,
	 *  reject: Function
	 * }>}
	 */
	static #taskWorkerQueue = [];

	/**
	 * @typedef {Object} BatchResponse
	 * @property {Object} batchManager
	 * @property {function(function(number, Object, Object): void): boolean} batchManager.offFinishedItemCallback
	 * @property {function(function(number, Object, Object): void): void} batchManager.onFinishedItemCallback
	 * @property {function(!number): void} batchManager.setProbe Number of actions after each a notification is displayed allowing to prematurely cancel the batch.
	 * @property {function((?boolean|?number)): void} batchManager.setNotificationProgress Display a notification after every `n`-th finished action. Set to `false` to disable. Set to `null` to use global value.
	 * @property {function(): void} batchManager.stop
	 * @property {Promise<void>} promise
	 */

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

	/**
	 * Returns whether a candidate statement should be matched.
	 * Returns `true`, if the statement should be matched, `false` if not and `null` for different purposes depending on the use of the matcher.
	 *
	 * @typedef {function(WikidataPain.Statement, WikidataPain.Statement, Object) : Promise<boolean>} QualifierMatcher
	 */

	/**
	 * @enum {WikidataPain.ComparisonMethod}
	 */
	static ComparisonMethod = {
		/**
		 * All qualifiers of the object statement must be identical.
		 * And for every qualifier property in the object there must be a qualifier using the same property in the subject.
		 */
		equal: 'equal',

		/**
		 * All qualifiers of the object statement must be equally or less precise.
		 * And for every qualifier property in the object there must be a qualifier using the same property in the subject.
		 */
		precision: 'precision'
	};

	/**
	 * @enum {function() : QualifierMatcher}
	 */
	static QualifierMatcherFactory = {
		/**
		 * Accept all qualifiers.
		 *
		 * @return {QualifierMatcher}
		 */
		all: () => {
			return async () => true;
		},

		/**
		 * Requires that the list of a candidate statement's qualifiers is a subset of the reference statement's qualifiers.
		 *
		 * @param {Object} [config]
		 * @param {Array<string>} [config.checkedProperties] Do not use, if `config.ignoredProperties` is set. Property-IDs to check. If not defined, then all properties are checked.
		 * @param {WikidataPain.ComparisonMethod} [config.comparisonMethod]
		 * @param {Array<string>} [config.ignoredProperties] Property-IDs to not check.
		 * @return {QualifierMatcher}
		 */
		candidateSubset: ( { checkedProperties = null, comparisonMethod = null, ignoredProperties = null } = {} ) => {
			return async ( candidateStatement, wholeStatement ) => {
				if ( candidateStatement.qualifiers === null ) {
					// Candidate has no qualifiers
					return true;
				}

				if ( candidateStatement.qualifiers !== null && wholeStatement.qualifiers === null ) {
					// Whole has no qualifiers
					return Object.keys( candidateStatement.qualifiers ).every( ( property ) => {
						if ( ( ignoredProperties ?? null ) !== null ) {
							// `true`, if property is blacklisted
							return ignoredProperties.includes( property );
						}

						if ( ( checkedProperties ?? null ) !== null ) {
							// `true`, if property is not whitelisted
							return !checkedProperties.includes( property );
						}

						// `false`, since candidate has more qualifiers than whole
						return false;
					} );
				}

				/**
				 * @param {string} qualifierToCheck
				 * @return {boolean}
				 */
				const partialQualifierCheck = ( qualifierToCheck ) => {
					if ( ( ignoredProperties ?? null ) !== null && ignoredProperties.includes( qualifierToCheck ) ) {
						// If ignored, then accept
						return true;
					}

					if ( ( checkedProperties ?? null ) !== null && !checkedProperties.includes( qualifierToCheck ) ) {
						// If not whitelisted, then accept
						return true;
					}

					return (
						( Object.keys( wholeStatement.qualifiers ).includes( qualifierToCheck ) ) && // Whole has qualifier
						candidateStatement.qualifiers[ qualifierToCheck ].every( ( partialSnak ) => {
							return wholeStatement.qualifiers[ qualifierToCheck ].some( ( wholeSnak ) => {
								if ( partialSnak.snaktype !== wholeSnak.snaktype ) {
									// `false`, if snaktypes mismatch
									return false;
								}

								if ( comparisonMethod === WikidataPain.ComparisonMethod.equal ) {
									return WikidataPain.DataValue.fromRaw( partialSnak.datavalue ).equals( WikidataPain.DataValue.fromRaw( wholeSnak.datavalue ) );
								} else {
									return [ WikidataPain.PrecisionComparison.EqualValue, WikidataPain.PrecisionComparison.MorePrecise ].includes( WikidataPain.DataValue.fromRaw( partialSnak.datavalue ).comparePrecision( WikidataPain.DataValue.fromRaw( wholeSnak.datavalue ) ) );
								}
							} );
						} )
					);
				};

				return Object.keys( candidateStatement.qualifiers ).every( partialQualifierCheck );
			};
		}
	};

	/**
	 * @enum {WikidataPain.ReferenceMatchingCriterium}
	 */
	static ReferenceMatchingCriterium = {
		/**
		 * There must be at least one claim that is shared by the subject and object.
		 */
		overlap: 'overlap',

		/**
		 * All claims of the object reference must be equally or less precise.
		 * And for every qualifier property in the object there must be a qualifier using the same property in the subject.
		 */
		precision: 'precision'
	};

	/**
	 * @param {Object|string} [commands]
	 * @param {number} [probe]
	 * @return {Promise<void>}
	 */
	static async adhoc( commands = undefined, probe = undefined ) {
		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;
			}
		}

		return await WikidataPain.batch( commands, async ( claim ) => {
			return await WikidataPain.#adhocCommand( claim );
		}, probe ).promise;
	}

	/**
	 * Executes a single adhoc-command.
	 *
	 * @param {Object} command
	 * @param {string} command.action
	 * @param {any} command.claim
	 * @param {Object} command.config
	 * @param {boolean} [command.config.allowCreate=false]
	 * @param {string} command.config.entity ID of entity. Use `current` for current entities' ID.
	 * @param {('new'|'refine')} command.config.method
	 * @param {WikidataPain.ComparisonMethod} [command.config.qualifierComparisonMethod=WikidataPain.ComparisonMethod.precision]
	 * @param {WikidataPain.ReferenceMatchingCriterium} [command.config.referenceRefinableCriterium=WikidataPain.ReferenceMatchingCriterium.overlap]
	 * @return {Promise<void>}
	 */
	static async #adhocCommand( command ) {
		// Validate command
		const config = command.config ?? null;

		// Check for unknown properties
		if ( config !== null ) {
			const unknownKeys = Object.keys( config ).filter( ( key ) => {
				return ![ 'allowCreate', 'entity', 'method', 'qualifierComparisonMethod', 'referenceRefinableCriterium' ].includes( key );
			} );

			if ( unknownKeys.length > 0 ) {
				throw new Error( `There are unknown configuration properties: ${unknownKeys.sort().join( ', ' )}` );
			}
		}

		// Expand entity ID, if needed
		if ( config?.entity === 'current' ) {
			config.entity = WikidataPain.Util.currentEntityId();
		}

		// Parse claim
		let claim;
		if ( typeof claim === 'string' ) {
			claim = JSON.parse( claim );
		} else {
			claim = command.claim;
		}

		// Execute
		switch ( command.action ) {
			case 'wbsetclaim':
				// Validate
				if ( config !== null ) {
					for ( const parameter of [ 'entity', 'method' ] ) {
						if ( ( config[ parameter ] ?? null ) === null ) {
							throw new Error( `${parameter} must be defined.` );
						}
					}
				}

				/** @type {string} */
				const entity = config.entity;
				const statement = WikidataPain.Statement.fromRawNew( entity, claim );

				if ( config.method === 'new' ) {
					return statement.commit();
				} else if ( config.method === 'refine' ) {
					return statement.refine( config?.allowCreate ?? false, {
						qualifierComparisonMethod: config?.qualifierComparisonMethod ?? WikidataPain.ComparisonMethod.precision,
						referenceRefinableCriterium: config?.referenceRefinableCriterium ?? WikidataPain.ReferenceMatchingCriterium.overlap
					} );
				}
				break;
			default:
				throw new Error( `Unknown action ${command.action}.` );
		}
	}

	/**
	 * @template T
	 * @param {Array<T>} claims
	 * @param {function(T): Promise<(string|void)?>} action
	 * @param {!number} [probe] Number of actions after each a notification is displayed allowing to prematurely cancel the batch.
	 * @param {Object} [config]
	 * @param {boolean|number} [config.notificationProgress] = Display a notification after every `n`-th finished action. Set to `false` to disable. Set to `null` to use global value.
	 * @return {BatchResponse}
	 */
	static batch( claims, action, probe = null, config = {} ) {
		const batchManager = {
			_finishedItemCallbacks: [],
			_notificationProgress: config.notificationProgress,
			_probe: probe ?? ( claims.length > 10 ? 1 : 0 ),
			_stopped: false,
			offFinishedItemCallback: function ( callback ) {
				const index = this._finishedItemCallbacks.indexOf( callback );
				if ( index !== -1 ) {
					this._finishedItemCallbacks.splice( index, 1 );
					return true;
				} else {
					return false;
				}
			},
			onFinishedItemCallback: function ( callback ) {
				this._finishedItemCallbacks.push( callback );
			},
			setProbe: function ( newValue ) {
				this._probe = newValue;
			},
			setNotificationProgress: function ( newValue ) {
				this._notificationProgress = newValue;
			},
			stop: function () {
				this._stopped = true;
			}
		};

		return {
			manager: batchManager,
			promise: new Promise( async () => {
				for ( const [ i, claim ] of Object.entries( claims ) ) {
					const index = parseInt( i );
					let repeat;
					do {
						repeat = false;

						if ( WikidataPain.#batchStop || batchManager._stopped ) {
							alert( `Stopped at ${index + 1}/${claims.length}.` );
							throw new Error( 'Batch cancelled' );
						}

						let result;
						try {
							await action( claim );
						} catch ( ex ) {
							let answer = null;
							do {
								if ( ( ex ?? null ) !== null ) {
									console.error( ex );
								}

								let errorMessage = null;
								if ( typeof ex === 'string' ) {
									errorMessage = ex;
								} else if ( typeof ex?.message === 'string' ) {
									errorMessage = ex.message;
								} else {
									const jsonError = JSON.stringify( ex );
									if ( jsonError !== '{}' ) {
										errorMessage = jsonError;
									}
								}
								answer = prompt( `${errorMessage !== null ? `${errorMessage}\n\n` : ''}Action failed for #${i} ${JSON.stringify( claim )}. 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':
									throw new Error( ex );
								case 'r':
									repeat = true;
									continue;
								case 's':
									continue;
								case null:
									throw new Error( 'Batch cancelled' );
							}
						}

						console.log( `${index + 1}/${claims.length} finished.` );
						for ( const callback of batchManager._finishedItemCallbacks ) {
							callback.call( batchManager, index, claim, claims );
						}

						const notificationProgress = batchManager._notificationProgress ?? WikidataPain.NOTIFICATIONS_PROGRESS;
						if ( notificationProgress > 0 && ( index + 1 ) % +notificationProgress === 0 || notificationProgress !== false && index === 0 || notificationProgress !== false && index === claims.length - 1 ) {
							GM_notification( `${index + 1}/${claims.length}${typeof result === 'string' ? ` ${result}` : ''}`, 'WikidataPain' );
						}

						if ( index < batchManager._probe && !confirm( `Finished ${index + 1}/${claims.length}. Probe-mode is enabled and set to ${batchManager._probe}.\n\nDo you wish to continue?` ) ) {
							throw new Error( 'Batch cancelled' );
						}
					} while ( repeat );
				}
			} )
		};
	}

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

	/**
	 * Adds statements using QuickStatements.
	 *
	 * @param {string} commandBlock
	 * @param {Object} [config]
	 * @param {string} [config.editgroup]
	 * @param {number} [config.probe]
	 * @param {string} [config.summary]
	 * @return {Promise<Object>}
	 */
	static async createQuickStatements( commandBlock, { editgroup = null, probe = null, summary = null } = {} ) {
		const qsCommands = commandBlock.split( '\n' ).map( ( line ) => {
			return line.split( '\t' );
		} );
		const commands = await Promise.all( qsCommands.map( async ( command ) => {
			return await WikidataPain.Util.quickStatementsLineToStatement( command );
		} ) );

		return await WikidataPain.batch( commands, ( command ) => {
			return command.commit( {
				editgroup: editgroup,
				summary: summary
			} );
		}, probe ).promise;
	}

	/**
	 * Makes a generic GET-request to the Wikidata-MediaWiki-Actions-API.
	 *
	 * @param {Object} requestBody
	 * @return {Promise<Object>}
	 */
	static async get( requestBody ) {
		const e = await fetch( `${WikidataPain.ENDPOINT}?${new URLSearchParams( {
			format: 'json',
			maxlag: WikidataPain.MAXLAG,
			...requestBody
		} ) }`, {
			headers: {
				'Accept': 'application/json',
			}
		} );
		const data = await e.json();

		if ( Object.prototype.hasOwnProperty.call( data, 'error' ) ) {
			console.error( data );
			throw new Error( `Wikidata API returned error: ${JSON.stringify( data.error )}` );
		}

		return data;
	}

	/**
	 * @private
	 */
	static interceptorPrint() {
		const logItems = WikidataPain.#interceptorLog.map( ( logItem ) => {
			return JSON.stringify( logItem );
		} ).join( ',\n' );
		console.debug( `[${logItems}]` );
	}

	/**
	 * Toggles the interceptor.
	 */
	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' );
		}
	}

	/**
	 * Makes a generic POST-request to the Wikidata-MediaWiki-Actions-API.
	 *
	 * @param {Object} requestBody
	 * @return {Promise<Object>}
	 */
	static async post( requestBody ) {
		const e = await fetch( WikidataPain.ENDPOINT, {
			body: new URLSearchParams( {
				format: 'json',
				maxlag: WikidataPain.MAXLAG,
				...requestBody
			} ),
			headers: {
				'Accept': 'application/json',
			},
			method: 'POST'
		} );
		const data = await e.json();

		if ( Object.prototype.hasOwnProperty.call( data, 'error' ) ) {
			console.error( data );
			throw new Error( `Wikidata API returned error: ${JSON.stringify( data.error )}` );
		}

		return data;
	}

	/**
	 * Makes a generic POST-request with a CSRF-token to the Wikidata-MediaWiki-Actions-API.
	 *
	 * @param {Object} requestBody
	 * @return {Promise<Object>}
	 */
	static async postCsrf( requestBody ) {
		const token = await WikidataPain.queryCsrfToken();

		return await WikidataPain.post( {
			token: token,
			...requestBody
		} );
	}

	/**
	 * Requests a CSRF-token from the Wikidata-API.
	 *
	 * @return {Promise<string>} CSRF-token.
	 */
	static async queryCsrfToken() {
		const data = await WikidataPain.get( {
			action: 'query',
			meta: 'tokens',
			type: 'csrf'
		} );
		return data.query.tokens.csrftoken;
	}

	/**
	 * @param {!string} entity
	 * @param {!string} property
	 * @param {Object} [config]
	 * @param {string} [config.editgroup=null]
	 * @param {string} [config.summary=null]
	 * @return {Promise<Array<WikidataPain.Statement>>} Other statements sharing the same property that have been edited.
	 */
	static async rankByReasonInstant( entity, property, { editgroup = null, summary = null } = {} ) {
		const statements = await WikidataPain.Statement.fromEntityCollapsed( entity, property );

		const withReason = statements.filter( async ( statement ) => {
			const qualifiers = await statement.getQualifiers();
			return WikidataPain.PROPERTY_REASON_FOR_DEPRECATION in qualifiers || WikidataPain.PROPERTY_REASON_FOR_PREFERENCE in qualifiers;
		} );

		if ( withReason.length === 0 ) {
			throw new Error( 'Found no statements with reason for deprecation/preference' );
		}

		const edited = [];
		for ( const statement of statements ) {
			const qualifiers = await statement.getQualifiers();
			let newRank;
			if ( WikidataPain.PROPERTY_REASON_FOR_DEPRECATION in qualifiers ) {
				newRank = WikidataPain.Rank.Deprecated;
			} else if ( WikidataPain.PROPERTY_REASON_FOR_PREFERENCE in qualifiers ) {
				newRank = WikidataPain.Rank.Preferred;
			} else {
				newRank = WikidataPain.Rank.Normal;
			}

			const oldRank = await statement.getRank();
			if ( newRank !== oldRank ) {
				await statement.setRankInstant( newRank, oldRank, {
					editgroup: editgroup,
					summary: summary
				} );
				edited.push( statement );
			}
		}

		return edited;
	}

	/**
	 * Adds statements using QuickStatements.
	 *
	 * @param {string} commandBlock
	 * @param {boolean} [allowCreate]
	 * @param {Object} [config]
	 * @param {string} [config.editgroup]
	 * @param {number} [config.probe]
	 * @param {WikidataPain.ComparisonMethod} [config.qualifierComparisonMethod]
	 * @param {QualifierMatcher} [config.qualifierMatcher]
	 * @param {WikidataPain.ReferenceMatchingCriterium} [config.referenceRefinableCriterium=WikidataPain.ReferenceMatchingCriterium.overlap]
	 * @param {string} [config.summary]
	 * @return {Promise<Object>}
	 */
	static async refineQuickStatements( commandBlock, allowCreate = false, { editgroup = null, probe = null, qualifierComparisonMethod = null, qualifierMatcher = null, referenceRefinableCriterium = null, summary = null } = {} ) {
		const qsCommands = commandBlock.split( '\n' ).map( ( line ) => {
			return line.split( '\t' );
		} ).filter( ( line ) => {
			return line.length > 0 && !( line.length === 1 && line[ 0 ] === '' );
		} );
		const commands = await Promise.all( qsCommands.map( async ( command ) => {
			return await WikidataPain.Util.quickStatementsLineToStatement( command );
		} ) );

		return await WikidataPain.batch( commands, async ( command ) => {
			return await command.refine( allowCreate, {
				editgroup: editgroup,
				qualifierComparisonMethod: qualifierComparisonMethod,
				qualifierMatcher: qualifierMatcher,
				referenceRefinableCriterium: referenceRefinableCriterium,
				summary: summary
			} );
		}, probe ).promise;
	}

	/**
	 * @param {string} [commandBlock]
	 * @param {boolean} [allowCreate]
	 * @param {Object} [config]
	 * @param {boolean} [config.confirmBefore]
	 * @param {string} [config.editgroup]
	 * @param {number} [config.probe]
	 * @param {WikidataPain.ComparisonMethod} [config.qualifierComparisonMethod]
	 * @param {QualifierMatcher} [config.qualifierMatcher]
	 * @param {WikidataPain.ReferenceMatchingCriterium} [config.referenceRefinableCriterium]
	 * @return {Promise<void>}
	 */
	static async refineRsolver( commandBlock = null, allowCreate = false, { confirmBefore = null, editgroup = null, probe = null, qualifierComparisonMethod = null, qualifierMatcher = null, referenceRefinableCriterium = null } = {} ) {
		const resolved = await WikidataPain.rsolver( commandBlock );

		await WikidataPain.taskWorker( async () => {
			if ( resolved.length === 0 ) {
				return;
			}

			if ( confirmBefore && !confirm( 'Received statements from Rsolver.\nDo you wish to commit them? If not, they are discarded.' ) ) {
				return;
			}

			await WikidataPain.refineQuickStatements( resolved, allowCreate, {
				editgroup: editgroup,
				probe: probe,
				qualifierComparisonMethod: qualifierComparisonMethod,
				qualifierMatcher: qualifierMatcher,
				referenceRefinableCriterium: referenceRefinableCriterium
			} );
		} );
	}

	/**
	 * Resolves a command block as string using Rsolver.
	 *
	 * @param {string} [commandBlock]
	 * @return {Promise<string>}
	 */
	static rsolver( commandBlock = null ) {
		return new Promise( async ( resolve, reject ) => {
			const MAX_ATTEMPTS = 10;

			if ( WikidataPain.#outputPipeHandlerRegistered ) {
				reject( Error( 'Currently only one pipe to Rsolver may be opened per session' ) );
			}

			// Open window
			const popup = window.open( WikidataPain.RSOLVER );
			if ( popup === null ) {
				reject( Error( 'Failed to open Rsolver, enable pop-ups if your browser requests you to' ) );
			}

			// Add event handler
			if ( !WikidataPain.#outputPipeHandlerRegistered ) {
				WikidataPain.#outputPipeHandlerRegistered = true;
				window.addEventListener( 'message', ( e ) => {
					switch ( e.data.type ) {
						case 'rsolver.outputPipe':
							resolve( e.data.data.success );
							break;
						case 'rsolver.outputPipeRegistered':
							WikidataPain.#outputPipeReady = true;
							break;
					}
				} );
			}

			// Enable output pipe
			let attempts = 0;
			do {
				console.log( 'Attempting to enable output pipe in Rsolver' );
				attempts++;
				popup.postMessage( {
					type: 'outputPipeEnable',
					pipeIdentifier: WikidataPain.Util.randomUuidV4()
				}, WikidataPain.RSOLVER );

				if ( !WikidataPain.#outputPipeReady ) {
					await WikidataPain.Util.sleep( 750 );
				}
			} while ( !WikidataPain.#outputPipeReady && attempts < MAX_ATTEMPTS );

			if ( WikidataPain.#outputPipeReady ) {
				// Success!
				console.log( 'Output pipe successfully enabled' );

				if ( commandBlock !== null ) {
					popup.postMessage( {
						type: 'setInput',
						value: commandBlock
					}, WikidataPain.RSOLVER );
				}
			}
		} );
	}

	/**
	 * Maintains a shared queue for all tasks registered using this method.
	 *
	 * @param {function(): Promise<void>} action
	 * @return {Promise<void>}
	 */
	static async taskWorker( action ) {
		await new Promise( ( resolve, reject ) => {
			// Add to queue
			WikidataPain.#taskWorkerQueue.push( {
				action: action,
				resolve: resolve,
				reject: reject
			} );

			if ( WikidataPain.#taskWorker === null ) {
				WikidataPain.#taskWorkerWork();
			}
		} );
	}



	/**
	 * Requests data from Wikidata Query Service.
	 *
	 * @param {string} query
	 * @param {boolean} [transform=false]
	 * @return {Promise<Array<Object>>}
	 */
	static async wdqs( query, transform = false ) {
		const e = await fetch( `${WikidataPain.SPARQL_ENDPOINT}?${new URLSearchParams( {
			format: 'json',
				query: query
		} ) }`, {
			headers: {
				'Accept': 'application/json',
			}
		} );

		const data = await e.json();
		let bindings = data?.results?.bindings ?? null;

		if ( bindings === null ) {
			throw new Error( data );
		}

		if (transform) {
			bindings = await Promise.all( bindings.map( async ( binding ) => {
				return Object.fromEntries( await Promise.all( Object.entries( binding ).map( async ( [ k, v ] ) => {
					let newValue = await WikidataPain.wdqsTransformBinding( v );
					return [ k, newValue ];
				} ) ) );
			} ) );
		}

		return bindings;
	}

	/**
	 * Transforms a single WDQS binding to a corresponding WikidataPain representation.
	 *
	 * @param {Object} binding
	 * @return {Promise<any>}
	 */
	static async wdqsTransformBinding( binding ) {
		if ( binding.type === 'uri' ) {
			/** @type {RegExpMatchArray} */
			const entityMatch = binding.value.match( /^http:\/\/www\.wikidata\.org\/entity\/(?<entityId>[A-Z]\d+)/ );
			if (entityMatch !== null) {
				return WikidataPain.DataValue.fromEntity( entityMatch.groups.entityId );
			}

			/** @type {RegExpMatchArray} */
			const stmtMatch = binding.value.match( /^http:\/\/www\.wikidata\.org\/entity\/statement\/(?<entityId>[A-Z]\d+)-(?<claimUuid>.+)/ );
			if (stmtMatch !== null) {
				return WikidataPain.Statement.fromClaimId( `${stmtMatch.groups.entityId}$${stmtMatch.groups.claimUuid}` );
			}
		}

		console.error( binding );
		throw new Error( 'Unknown binding type' );
	}

	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: {
							allowCreate: true,
							entity: 'current',
							method: 'refine'
						},
						claim: outClaim
					} );

					break;
			}

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

	static async #taskWorkerWork() {
		do {
			// Get next task and start
			const nextTask = WikidataPain.#taskWorkerQueue.shift();
			WikidataPain.#taskWorker = nextTask.action();

			// Wait until finished
			try {
				await WikidataPain.#taskWorker;
				nextTask.resolve();
			} catch ( ex ) {
				nextTask.reject( ex );
			}
		} while ( WikidataPain.#taskWorkerQueue.length > 0 );
		WikidataPain.#taskWorker = null;
	}
}

/**
 * @class
 */
WikidataPain.DataValue = class {
	/** @type {WikidataPain.DataValue.Type} */
	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( WikidataPain.DataValue.Type.WikibaseEntityid, {
			id: entityId
		} );
	}

	/**
	 * @param {string} languageCode
	 * @param {string} text
	 * @return {WikidataPain.DataValue}
	 */
	static fromMonolingualText( languageCode, text ) {
		return new WikidataPain.DataValue( WikidataPain.DataValue.Type.Monolingualtext, {
			language: languageCode,
			text: text
		} );
	}

	/**
	 * @param {string} amount
	 * @param {string} precision
	 * @param {string} unit
	 * @return {WikidataPain.DataValue}
	 */
	static fromQuantity( amount, precision, unit ) {
		return new WikidataPain.DataValue( WikidataPain.DataValue.Type.Quantity, {
			amount: amount,
			precision: precision,
			unit: unit
		} );
	}

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

	/**
	 * @param {number|string} string
	 * @return {WikidataPain.DataValue}
	 */
	static fromString( string ) {
		return new WikidataPain.DataValue( WikidataPain.DataValue.Type.String, String( string ) );
	}

	/**
	 * @param {number|string} year Year, or alternatively date string in the format `yyyy-mm-dd`.
	 * @param {number} [month=null]
	 * @param {number} [day=null]
	 * @return {WikidataPain.DataValue}
	 */
	static fromTime( year, month = null, day = null ) {
		if ( month === null && day === null && typeof year === 'string' && year.match( /\d{4}-\d{2}-\d{2}/ ) !== null ) {
			day = parseInt( year.split( '-' )[ 2 ] );

			month = parseInt( year.split( '-' )[ 1 ] );
			if ( month === 0 ) {
				month = null;
			}

			year = parseInt( year.split( '-' )[ 0 ] );
			if ( year === 0 ) {
				year = 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( WikidataPain.DataValue.Type.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.Util.dissectQsTimestamp( date );

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

	/**
	 * @param {number} precision
	 * @return {WikidataPain.DataValue}
	 */
	static fromToday( precision ) {
		const date = new Date();
		return WikidataPain.DataValue.fromTime( date.getFullYear(), precision >= 10 ? date.getMonth() + 1 : null, precision >= 11 ? date.getDate() : null );
	}

	/**
	 * 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 WikidataPain.DataValue.Type.Monolingualtext:
				if ( this.value.language === other.value.language && this.value.text === other.value.text ) {
					return WikidataPain.PrecisionComparison.EqualValue;
				} else {
					return WikidataPain.PrecisionComparison.IncomparableValue;
				}
			case WikidataPain.DataValue.Type.Quantity:
				if ( ( this.value.precision ?? null ) === ( other.value.precision ?? null ) && ( this.value.unit ?? null ) === ( other.value.unit ?? null ) ) {
					if ( this.value.amount === other.value.amount ) {
						return WikidataPain.PrecisionComparison.EqualValue;
					} else {
						return WikidataPain.PrecisionComparison.IncomparableValue;
					}
				} else {
					throw new Error( 'Not implemented' );
				}
			case WikidataPain.DataValue.Type.String:
				if ( this.value === other.value ) {
					return WikidataPain.PrecisionComparison.EqualValue;
				} else {
					return WikidataPain.PrecisionComparison.IncomparableValue;
				}
			case WikidataPain.DataValue.Type.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.Util.dissectQsTimestamp( `${this.value.time}/${this.value.precision}` ), WikidataPain.Util.dissectQsTimestamp( `${other.value.time}/${this.value.precision}` ), 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.MorePrecise;
				}
			case WikidataPain.DataValue.Type.WikibaseEntityid:
				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 ||
				comparisonPrecision >= WikidataPain.Precision.Month && lesser.month !== greater.month ||
				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 {string}
 */
WikidataPain.DataValue.Type = Object.freeze( {
	Monolingualtext: 'monolingualtext',
	Quantity: 'quantity',
	String: 'string',
	Time: 'time',
	WikibaseEntityid: 'wikibase-entityid'
} );

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

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

/**
 * @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
	 * @param {Object.<string, Array<WikidataPain.Snak>>} snaks
	 * @param {Array<string>} snakOrder
	 */
	constructor( snaks, snakOrder ) {
		this.snaks = snaks;
		this.snaksOrder = snakOrder;
	}

	/**
	 * @param {string} hash
	 * @return {Promise<WikidataPain.Reference>}
	 */
	static async fromHash( hash ) {
		hash = hash.replace( /^wdref:/, '' );

		const statements = await WikidataPain.Reference.statementsByReferenceHash( hash );

		if ( statements.length === 0 ) {
			throw new Error( `Unable to find statements that have a reference with hash ${hash}. Maybe the Wikidata Query Service is lagging behind?` );
		}

		const exemplaryStatement = statements[ 0 ];
		const match = ( await exemplaryStatement.getReferences() ).find( ( reference ) => {
			return reference.hash === hash;
		} );

		if ( match === undefined ) {
			throw new Error( `Statement didn't have reference with hash ${hash}. Maybe the Wikidata Query Service is lagging behind?` );
		}

		return match;
	}

	/**
	 * @param {Object} raw
	 * @return {WikidataPain.Reference}
	 */
	static fromRaw( raw ) {
		/** @type {Array<string>} */
		const snaksOrder = raw[ 'snaks-order' ];
		/** @type {Object.<string, Array<WikidataPain.Snak>>} */
		const snaks = {};
		const statement = new WikidataPain.Reference( snaks, snaksOrder );
		statement.hash = raw.hash;
		for ( const snakProperty of Object.keys( raw.snaks ) ) {
			statement.snaks[ snakProperty ] = raw.snaks[ snakProperty ].map( ( rawSnak ) => {
				return WikidataPain.Snak.fromRaw( rawSnak );
			} );
		}

		return statement;
	}

	/**
	 * @param {Array<WikidataPain.Snak>} snaks List of snaks.
	 * @param {Array<string>} [snaksOrder=null] Order of snaks, if not set than order of snaks in {@ref snaks} is used.
	 * @return {WikidataPain.Reference}
	 */
	static fromSnaks( snaks, snaksOrder = null ) {
		let newSnaksOrder;
		if ( snaksOrder === null ) {
			newSnaksOrder = WikidataPain.Util.makeArrayUnique( snaks.map( ( snak ) => snak.property ) );
		} else {
			newSnaksOrder = snaksOrder;
		}

		const reference = new WikidataPain.Reference( {}, newSnaksOrder );
		for ( const snak of snaks ) {
			reference.addSnak( snak );
		}

		return reference;
	}

	/**
	 * Returns a set of statements that have at least one reference with the given hash.
	 * The lookup is done using the Wikidata Query Service.
	 *
	 * @param {string} referenceHash
	 * @return {Promise<Array<WikidataPain.Statement>>}
	 */
	static async statementsByReferenceHash( referenceHash ) {
		const bindings = await WikidataPain.wdqs( `SELECT ?statement WHERE {
			?statement prov:wasDerivedFrom wdref:${referenceHash}.
		}`.replace( /[\n\t]+/g, ' ' ) );

		return bindings.map( ( binding ) => WikidataPain.Statement.fromClaimId( binding.statement.value ) );
	}

	/**
	 * Adds a snak to the reference.
	 *
	 * @param {WikidataPain.Snak} snak
	 * @return {WikidataPain.Reference}
	 */
	addSnak( snak ) {
		const property = snak.property;

		if ( this.snaks[ property ] === undefined ) {
			this.snaks[ property ] = [ snak ];
		} else {
			this.snaks[ property ].push( snak );
		}

		return this;
	}

	/**
	 * @param {WikidataPain.Reference} other
	 * @return {WikidataPain.PrecisionComparison}
	 */
	comparePrecision( other ) {
		return WikidataPain.Util.comparePropertyValues( this.snaks, other.snaks );
	}

	/**
	 * @param {string} property
	 * @param {WikidataPain.Snak} otherSnak
	 * @return {Array<WikidataPain.Snak>}
	 */
	filterSnaksByRefinable( property, otherSnak ) {
		if ( this.snaks[ property ] === undefined ) {
			return [];
		}

		return this.snaks[ property ].filter( ( thisSnak ) => {
			return thisSnak.comparePrecision( otherSnak ) !== WikidataPain.PrecisionComparison.IncomparableValue;
		} );
	}

	/**
	 * @param {WikidataPain.Reference} other
	 * @return {boolean}
	 */
	merge( other ) {
		let changed = false;
		/** @type {Object.<string, Array<WikidataPain.Snak>>} */
		const toAdd = {};

		for ( const [ property, otherSnaks ] of Object.entries( other.snaks ) ) {
			for ( const otherSnak of otherSnaks ) {
				const refinables = this.filterSnaksByRefinable( property, otherSnak );

				if ( refinables.length === 1 ) {
					console.debug( `[WikidataPain] Refine snak (property ${otherSnak.property}) of reference` );
					changed = changed || refinables[ 0 ].merge( otherSnak );
				} else if ( refinables.length === 0 ) {
					console.debug( `[WikidataPain] Add new snak (property ${otherSnak.property}) to reference` );

					if ( toAdd[ property ] === undefined ) {
						toAdd[ property ] = [ otherSnak ];
					} else {
						toAdd[ property ].push( otherSnak );
					}
					changed = true;
				} else {
					throw new Error( `Illegal number of refinable snaks of reference, found ${refinables.length}` );
				}
			}
		}

		for ( const [ property, snaks ] of Object.entries( toAdd ) ) {
			for ( const snak of snaks ) {
				if ( this.snaks[ property ] === undefined ) {
					this.snaks[ property ] = [];
				}
				this.snaks[ property ].push( snak );
			}
		}

		return changed;
	}

	/**
	 * @param {WikidataPain.Reference} other
	 * @return {boolean} Whether there is at least one Snak that is shared by both references.
	 */
	overlaps( other ) {
		return Object.entries( this.snaks ).some( ( [ property, thisSnaks ] ) => {
			if ( other.snaks[ property ] === undefined ) {
				// If the property is not defined in the other reference, there can't be an overlap for this property
				return false;
			}

			return thisSnaks.some( ( thisSnak ) => {
				return other.snaks[ property ].some( ( otherSnak ) => {
					return thisSnak.equals( otherSnak );
				} );
			} );
		} );
	}

	/**
	 * Removes a snak from the reference.
	 *
	 * @param {WikidataPain.Snak} snak
	 * @return {WikidataPain.Reference}
	 */
	removeSnak( snak ) {
		const property = snak.property;

		if ( this.snaks[ property ] === undefined ) {
			return this;
		}

		this.snaks[ property ] = this.snaks[ property ].filter( ( originalSnak ) => {
			return !originalSnak.equals( snak );
		} );

		return this;
	}

	/**
	 * Replaces all references that have the given hash across all statements.
	 * Querying of statements that contain a reference with the given hash is done via the Wikidata Query Service.
	 * This means, that there is a delay after which newly added references are detected.
	 *
	 * @param {string} hash Hash of the references to replace.
	 * @return {Promise<void>}
	 */
	async replaceHashInstant( hash ) {
		hash = hash.replace( /^wdref:/, '' );

		const statements = await WikidataPain.Reference.statementsByReferenceHash( hash );

		for ( const statement of statements ) {
			const foundReference = await statement.removeReferenceByHash( hash );

			if ( !foundReference ) {
				throw new Error( `Failed to find reference with hash ${hash}` );
			}

			await statement.addReference( this );
		}

		for ( const statement of statements ) {
			await statement.commit();
		}
	}

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

/**
 * @class
 */
WikidataPain.Snak = class {
	/** @type {WikidataPain.Snak.DataType} */
	datatype = null;

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

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

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

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

	/**
	 * @param {string} property
	 * @param {WikidataPain.Snak.DataType} datatype
	 * @param {WikidataPain.DataValue} datavalue
	 * @return {WikidataPain.Snak}
	 */
	static fromDataValue( property, datatype, datavalue ) {
		return new WikidataPain.Snak( WikidataPain.Snak.ValueType.Value, property, datatype, datavalue );
	}

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

	/**
	 * @param {string} property
	 * @param {string} languageCode
	 * @param {string} text
	 * @return {WikidataPain.Snak}
	 */
	static fromMonolingualText( property, languageCode, text ) {
		return WikidataPain.Snak.fromDataValue( property, WikidataPain.Snak.DataType.Monolingualtext, WikidataPain.DataValue.fromMonolingualText( languageCode, text ) );
	}

	/**
	 * @param {string} property
	 * @return {WikidataPain.Snak}
	 */
	static fromNoValue( property ) {
		return new WikidataPain.Snak( WikidataPain.Snak.ValueType.NoValue, property, null, null );
	}

	/**
	 * @param {string} property
	 * @param {string} amount
	 * @param {string} precision
	 * @param {string} unit
	 * @return {WikidataPain.Snak}
	 */
	static fromQuantity( property, amount, precision, unit ) {
		return WikidataPain.Snak.fromDataValue( property, WikidataPain.Snak.DataType.Quantity, WikidataPain.DataValue.fromQuantity( amount, precision, unit ) );
	}

	/**
	 * @param {string} property
	 * @param {string} value
	 * @return {WikidataPain.Snak}
	 */
	static fromQuickStatements( property, value ) {
		property = property.replace( /^S/, 'P' );

		if ( /^[PQ]\d+$/.test( value ) ) {
			// Item: https://www.wikidata.org/wiki/Special:MyLanguage/Help:Data_type#wikibase-item
			// Property: https://www.wikidata.org/wiki/Special:MyLanguage/Help:Data_type#wikibase-property
			return WikidataPain.Snak.fromEntity( property, value );
		} else if ( /^".+"$/.test( value ) ) {
			// String: https://www.wikidata.org/wiki/Special:MyLanguage/Help:Data_type#string
			return WikidataPain.Snak.fromString( property, value.match( /^"(.+)"$/ )[ 1 ] );
		} else if ( /^[a-z-]{2,}:".+"$/.test( value ) ) {
			// Monolingual text: https://www.wikidata.org/wiki/Special:MyLanguage/Help:Data_type#monolingualtext
			const match = value.match( /^([a-z-]{2,}):"(.+)"$/ );
			return WikidataPain.Snak.fromMonolingualText( property, match[ 1 ], match[ 2 ] );
		} else if ( /^\+\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\/\d{1,2}$/.test( value ) ) {
			// Time: https://www.wikidata.org/wiki/Special:MyLanguage/Help:Data_type#time
			const match = value.match( /^\+(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}Z\/(\d{1,2})$/ );
			return WikidataPain.Snak.fromTime( property, parseInt( match[ 1 ] ), parseInt( match[ 4 ] ) >= 10 ? parseInt( match[ 2 ] ) : null, parseInt( match[ 4 ] ) >= 11 ? parseInt( match[ 3 ] ) : null );
		} else if ( /^@\d{1,2}.\d+\/\d{1,2}.\d+/.test( value ) ) {
			// Globe coordinate: https://www.wikidata.org/wiki/Special:MyLanguage/Help:Data_type#globe-coordinate
			throw new Error( 'Not implemented' );
		} else if ( /^[+-]?\d+(~\d+)?(U\d+)?$/.test( value ) ) {
			// Quantity: https://www.wikidata.org/wiki/Special:MyLanguage/Help:Data_type#quantity
			const match = value.match( /^([+-])?(\d+)(?:(~\d+))?(?:U(\d+))?$/ );
			const sign = match[ 1 ] === undefined ? '+' : match[ 1 ];
			let unit;
			if ( match[ 4 ] === undefined ) {
				unit = '1';
			} else if ( /^Q\d+/.test( match[ 4 ] ) ) {
				unit = `http://www.wikidata.org/entity/${match[ 4 ]}`;
			} else {
				throw new Error( 'Failed to parse quantity' );
			}
			return WikidataPain.Snak.fromQuantity( property, `${sign}${match[ 2 ]}`, match[ 3 ] ?? null, unit );
		} else if ( value === 'somevalue' ) {
			return WikidataPain.Snak.fromSomevalue( property );
		} else if ( value === 'novalue' ) {
			return WikidataPain.Snak.fromNoValue( property );
		} else {
			throw new Error( 'Unexpected QuickStatement-value' );
		}
	}

	/**
	 * @param {string} property
	 * @return {WikidataPain.Snak}
	 */
	static fromSomevalue( property ) {
		return new WikidataPain.Snak( WikidataPain.Snak.ValueType.Somevalue, property, null, null );
	}

	/**
	 * @param {string} property
	 * @param {Object} sparql
	 * @return {WikidataPain.Snak}
	 */
	static fromSparql( property, sparql ) {
		switch ( sparql.type ) {
			case 'literal': {
				if ( sparql[ 'xml:lang' ] !== undefined ) {
					return WikidataPain.Snak.fromMonolingualText( property, sparql[ 'xml:lang' ], sparql.value );
				} else {
					return WikidataPain.Snak.fromString( property, sparql.value );
				}
			}
			case 'uri': {
				if ( /^http:\/\/www\.wikidata\.org\/entity\/(Q\d+)$/.test( sparql.value ) ) {
					return WikidataPain.Snak.fromEntity( property, sparql.value.match( /^http:\/\/www\.wikidata\.org\/entity\/(Q\d+)$/ )[ 1 ] );
				} else {
					break;
				}
			}
		}

		throw new Error( 'Unexpected SPARQL-object' );
	}

	/**
	 * @param {Object} raw
	 * @return {WikidataPain.Snak}
	 */
	static fromRaw( raw ) {
		return new WikidataPain.Snak( raw.snaktype ?? null, raw.property ?? null, raw.datatype, ( raw.datavalue ?? null ) === null ? 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.Snak.DataType.String, 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.Snak.DataType.Time, WikidataPain.DataValue.fromTime( year, month, day ) );
	}

	/**
	 * @param {string} property
	 * @param {string} timestamp
	 * @return {WikidataPain.Snak}
	 */
	static fromTimestamp( property, timestamp ) {
		return WikidataPain.Snak.fromDataValue( property, WikidataPain.Snak.DataType.Time, WikidataPain.DataValue.fromTimestamp( timestamp ) );
	}

	/**
	 * @param {string} property
	 * @param {number} precision
	 * @return {WikidataPain.Snak}
	 */
	static fromToday( property, precision ) {
		return WikidataPain.Snak.fromDataValue( property, WikidataPain.Snak.DataType.Time, WikidataPain.DataValue.fromToday( precision ) );
	}

	/**
	 * 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.ValueType.Somevalue ) {
				return WikidataPain.PrecisionComparison.LessPrecise;
			} else if ( other.snaktype === WikidataPain.Snak.ValueType.Somevalue ) {
				return WikidataPain.PrecisionComparison.MorePrecise;
			} 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;
	}

	/**
	 * @param {Array<number>} start
	 * @param {Array<number>} end
	 * @return {boolean}
	 */
	filterByTimeInterval( [ startYear = 0, startMonth = 0, startDay = 0 ], [ endYear = null, endMonth = null, endDay = null ] ) {
		if ( this.snaktype !== WikidataPain.Snak.ValueType.Value ||
			this.datavalue.type !== WikidataPain.DataValue.Type.Time ) {
			throw new Error( 'Not concrete time' );
		}

		const components = WikidataPain.Util.dissectQsTimestamp( `${this.datavalue.value.time}/${this.datavalue.value.precision}` );
		const start = startYear * 10000 + startMonth * 100 + startDay;
		const end = endYear === null ? Math.max() : endYear * 10000 + endMonth * 100 + endDay;
		const subject = components.year * 10000 + ( components.precision >= WikidataPain.Precision.Month ? components.month : 0 ) * 100 + ( components.precision >= WikidataPain.Precision.Day ? components.day : 0 );

		return start <= subject && subject <= end;
	}

	/**
	 * @param {WikidataPain.Snak} other
	 * @return {boolean} Whether a change occured.
	 */
	merge( other ) {
		const comparison = this.datavalue.comparePrecision( other.datavalue );
		switch ( comparison ) {
			case WikidataPain.PrecisionComparison.EqualValue:
				console.debug( '[WikidataPain] Skipping snak, both are equal' );
				return false;
			case WikidataPain.PrecisionComparison.LessPrecise:
				this.datatype = other.datatype;
				return true;
			case WikidataPain.PrecisionComparison.MorePrecise:
				console.debug( '[WikidataPain] Skipping snak, this one is more precise' );
				return false;
			default:
				throw new Error( `[WikidataPain] Illegal precision comparison result ${comparison}` );
		}
	}

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

		for ( const field of [ 'datatype', 'datavalue', 'property', 'snaktype' ] ) {
			if ( this[ field ] !== null ) {
				output[ field ] = this[ field ];
			}
		}

		return output;
	}

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

/**
 * @enum {string}
 */
WikidataPain.Snak.DataType = Object.freeze( {
	ExternalId: 'external-id',
	Monolingualtext: 'monolingualtext',
	Quantity: 'quantity',
	String: 'string',
	Time: 'time',
	Url: 'url',
	WikibaseItem: 'wikibase-item'
} );

/**
 * @enum {string}
 */
WikidataPain.Snak.ValueType = 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 = {};

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

	/** @type {Array<WikidataPain.Reference>} */
	references = [];

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

	/**
	 * 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 collapseApiDictionary( dictionary ) {
		const output = [];

		for ( const [ _key, value ] of Object.entries( dictionary ) ) {
			output.push( ...value );
		}

		return output;
	}

	/**
	 * Function used to transform a snak.
	 *
	 * @typedef {function(WikidataPain.Snak): Promise<(WikidataPain.Snak)>} SnakTransformer
	 */

	/**
	 * Function used to transform a statement.
	 *
	 * @typedef {function(WikidataPain.Statement): Promise<(WikidataPain.Statement)>} StatementTransformer
	 */

	/**
	 * @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.collapseApiDictionary(
				/** @type {Object.<string, Array<WikidataPain.Statement>>} */( claims )
			);
		} else {
			collapsedClaims = claims;
		}

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

	/**
	 * @param {Array<WikidataPain.Statement>} claimsToFilter
	 * @param {WikidataPain.Snak} snak
	 * @return {Promise<Array<WikidataPain.Statement>>}
	 */
	static async filterStatementsByMainsnak( claimsToFilter, snak ) {
		return await WikidataPain.Util.filterAsync( claimsToFilter, async ( claim ) => {
			const mainsnak = await claim.getMainsnak();

			return mainsnak.equals( snak );
		} );
	}

	/**
	 * @param {Array<WikidataPain.Statement>} claimsToFilter
	 * @param {WikidataPain.Snak} qualifier
	 * @return {Promise<Array<WikidataPain.Statement>>}
	 */
	static async filterStatementsByQualifier( claimsToFilter, qualifier ) {
		return await WikidataPain.Util.filterAsync( claimsToFilter, async ( claim ) => {
			if ( claim.qualifiers === null || claim.qualifiers[ qualifier.property ] === undefined ) {
				return false;
			} else {
				return claim.qualifiers[ qualifier.property ].some( ( claimQualifier ) => {
					return claimQualifier.equals( qualifier );
				} );
			}
		} );
	}

	/**
	 * @param {Array<WikidataPain.Statement>} claimsToFilter
	 * @param {WikidataPain.Statement} whole
	 * @param {QualifierMatcher} [qualifierMatcher]
	 * @return {Promise<Array<WikidataPain.Statement>>}
	 */
	static async filterStatementsByQualifiersSubset( claimsToFilter, whole, qualifierMatcher ) {
		return await WikidataPain.Util.filterAsync( claimsToFilter, async ( claim ) => {
			return await qualifierMatcher( claim, whole );
		} );
	}

	/**
	 * @param {Array<WikidataPain.Statement>} claimsToFilter
	 * @param {WikidataPain.Statement} whole
	 * @param {Object} [config]
	 * @param {QualifierMatcher} [config.qualifierMatcher]
	 * @return {Promise<Array<WikidataPain.Statement>>}
	 */
	static async filterStatementsByRefinable( claimsToFilter, whole, { qualifierMatcher = null } = {} ) {
		const filteredByMainsnak = await WikidataPain.Statement.filterStatementsByMainsnak( claimsToFilter, await whole.getMainsnak() );
		const filteredByQualifiers = await WikidataPain.Statement.filterStatementsByQualifiersSubset( filteredByMainsnak, whole, qualifierMatcher );

		return filteredByQualifiers;
	}

	/**
	 * @param {string} entity Wikidata Q-ID.
	 * @param {WikidataPain.Snak} mainsnak Mainsnak.
	 * @param {WikidataPain.Rank} [rank=WikidataPain.Rank.Normal]
	 * @return {WikidataPain.Statement}
	 */
	static fromBlank( entity, mainsnak, rank = WikidataPain.Rank.Normal ) {
		const newClaimId = WikidataPain.Util.randomClaimId( entity );

		const statement = new WikidataPain.Statement( newClaimId, true, true );
		statement.#referencesLoaded = true;
		statement.mainsnak = mainsnak;
		statement.rank = rank;
		return statement;
	}

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

		if ( normalisedClaimId === null ) {
			throw new Error( `Failed to normalise claim ID ${claimId}` );
		}

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

	/**
	 * @param {string} entity Wikidata Q-ID.
	 * @param {string} [property] Wikidata P-ID.
	 * @param {boolean} [loadReferences=false] Whether to load references.
	 * @return {Promise<Object.<string, Array<WikidataPain.Statement>>>}
	 */
	static async fromEntity( entity, property = null, loadReferences = false ) {
		const apiParams = {
			action: 'wbgetclaims',
			entity: entity,
			props: loadReferences ? 'references' : ''
		};
		if ( property !== null ) {
			apiParams.property = property;
		}

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

		for ( const keyProperty of Object.keys( data.claims ) ) {
			result[ keyProperty ] = data.claims[ keyProperty ].map( ( claim ) => {
				return WikidataPain.Statement.fromRaw( claim, loadReferences );
			} );
		}

		return result;
	}

	/**
	 * @param {string} entity Wikidata Q-ID.
	 * @param {string} property Wikidata P-ID.
	 * @param {boolean} [loadReferences=false] Whether to load references.
	 * @return {Promise<Array<WikidataPain.Statement>>}
	 */
	static async fromEntityCollapsed( entity, property, loadReferences = false ) {
		const claims = await WikidataPain.Statement.fromEntity( entity, property, loadReferences );
		return WikidataPain.Statement.collapseApiDictionary( claims );
	}

	/**
	 * @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, false );

		statement.#referencesLoaded = referencesLoaded;
		statement.extendFromRaw( raw );

		return statement;
	}

	/**
	 * @param {string} entity
	 * @param {Object} raw
	 * @return {WikidataPain.Statement}
	 */
	static fromRawNew( entity, raw ) {
		const claimId = WikidataPain.Util.randomClaimId( entity );
		const statement = new WikidataPain.Statement( claimId, true, true );

		statement.#referencesLoaded = true;
		statement.extendFromRaw( raw );

		return statement;
	}

	/**
	 * @param {Object} sparql
	 * @return {WikidataPain.Statement}
	 */
	static fromSparql( sparql ) {
		switch ( sparql.type ) {
			case 'uri': {
				if ( WikidataPain.Util.normaliseClaimId( sparql.value ) !== null ) {
					return WikidataPain.Statement.fromClaimId( sparql.value );
				} else {
					break;
				}
			}
		}

		throw new Error( 'Unexpected SPARQL-object' );
	}

	/**
	 * @param {WikidataPain.Reference} reference
	 * @return {Promise<void>}
	 */
	async addReference( reference ) {
		await this.#assertLoaded( true );

		if ( this.references === null ) {
			this.references = [ reference ];
		} else {
			this.references.push( reference );
		}
	}

	/**
	 * Is committed instantly and may not be chained.
	 *
	 * @param {WikidataPain.Reference} reference
	 * @param {Object?} [options]
	 * @return {Promise<void>}
	 */
	async addReferenceInstant( reference, options = {} ) {
		return await WikidataPain.postCsrf( WikidataPain.Util.commonParameters( {
			action: 'wbsetreference',
			snaks: JSON.stringify( reference.snaks ),
			'snaks-order': JSON.stringify( reference[ 'snaks-order' ] ),
			statement: this.id
		}, options ) );
	}

	/**
	 * @param {Object} config
	 * @param {string} [config.editgroup]
	 * @param {string} [config.summary]
	 * @return {Promise<Object>}
	 */
	async commit( { editgroup = null, summary = null } = {} ) {
		await this.#assertLoaded( true );
		return await this.setClaim( { editgroup: editgroup, summary: summary } );
	}

	/**
	 * Deletes the statement using `wbremoveclaims`.
	 * Is committed instantly and may not be chained.
	 *
	 * @param {Object?} [options]
	 * @return {Promise<void>}
	 */
	async deleteInstant( options = {} ) {
		return await WikidataPain.postCsrf( WikidataPain.Util.commonParameters( {
			action: 'wbremoveclaims',
			claim: this.id
		}, options ) );
	}

	/**
	 * @param {Object} raw
	 * @return {WikidataPain.Statement}
	 */
	extendFromRaw( raw ) {
		this.mainsnak = ( raw.mainsnak ?? null ) === null ? null : WikidataPain.Snak.fromRaw( raw.mainsnak );
		this.rank = raw.rank ?? null;
		this.type = raw.type ?? null;

		/** @type {Object.<string, Array<WikidataPain.Snak>>} */
		const qualifiers = {};
		if ( raw.qualifiers !== null && raw.qualifiers !== undefined ) {
			for ( const property of Object.keys( raw.qualifiers ) ) {
				qualifiers[ property ] = raw.qualifiers[ property ].map( ( qualifier ) => {
					return WikidataPain.Snak.fromRaw( qualifier );
				} );
			}
		}

		this.qualifiers = qualifiers;
		this.qualifiersOrder = raw[ 'qualifiers-order' ] ?? [];
		this.references = raw.references ?? [];

		return this;
	}

	/**
	 * @param {Array<function(WikidataPain.Statement): Promise<boolean>>} predicates
	 * @return {Promise<boolean>}
	 */
	async filterAnd( predicates ) {
		const evaluatedPredicates = await Promise.all( predicates.map( ( predicate ) => {
			return predicate( this );
		} ) );

		return evaluatedPredicates.every( ( evaluatedPredicate ) => {
			return evaluatedPredicate;
		} );
	}

	/**
	 * @param {string} property
	 * @param {function(Array<WikidataPain.Snak>): Promise<boolean>} predicate
	 * @return {Promise<boolean>}
	 */
	async filterByQualifier( property, predicate ) {
		await this.#assertLoaded( false );

		if ( this.qualifiers === null || this.qualifiers[ property ] === undefined ) {
			return await predicate( [] );
		} else {
			return await predicate( this.qualifiers[ property ] );
		}
	}

	/**
	 * @param {function(Object.<string, Array<WikidataPain.Snak>>): Promise<boolean>} predicate
	 * @return {Promise<boolean>}
	 */
	async filterByQualifiers( predicate ) {
		await this.#assertLoaded( false );

		return await predicate( this.qualifiers );
	}

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

		return await predicate( mainsnak.datavalue );
	}

	/**
	 * @param {string} property
	 * @return {Promise<boolean>}
	 */
	async filterByNoQualifierSnaks( property ) {
		const qualifiers = await this.getQualifierSnaks( property );

		return qualifiers.length === 0;
	}

	/**
	 * @param {Array<function(WikidataPain.Statement): Promise<boolean>>} predicates
	 * @return {Promise<boolean>}
	 */
	async filterOr( predicates ) {
		const evaluatedPredicates = await Promise.allSettled( predicates.map( ( predicate ) => {
			return predicate( this );
		} ) );

		return evaluatedPredicates.some( ( evaluatedPredicate ) => {
			return evaluatedPredicate.status === 'fulfilled' && evaluatedPredicate.value;
		} );
	}

	/**
	 * @param {Object} [config]
	 * @param {QualifierMatcher} [config.qualifierMatcher]
	 * @return {Promise<Array<WikidataPain.Statement>>}
	 */
	async findPotentiallyRefinable( { qualifierMatcher = null } = {} ) {
		const entityId = WikidataPain.Util.entityIdFromClaimId( this.id );
		const property = this.mainsnak.property;
		const statements = await WikidataPain.Statement.fromEntity( entityId, property );

		if ( statements[ property ] === undefined ) {
			// No qualifier with property
			return [];
		}

		const filteredStatements = await WikidataPain.Statement.filterStatementsByRefinable( statements[ property ], this, {
			qualifierMatcher: qualifierMatcher
		} );

		if ( filteredStatements.length === 0 ) {
			return [];
		}

		return filteredStatements;
	}

	/**
	 * @return {string}
	 */
	getEntity() {
		return WikidataPain.Util.entityIdFromClaimId( this.id );
	}

	/**
	 * @return {Promise<WikidataPain.Snak>}
	 */
	async getMainsnak() {
		await this.#assertLoaded( false );

		return WikidataPain.Snak.fromRaw( this.mainsnak );
	}

	/**
	 * @param {boolean} [requestReferences] Whether to load references
	 * @return {Promise<Object>}
	 */
	async getObject( requestReferences = true ) {
		await this.#assertLoaded( requestReferences );

		return {
			id: this.id,
			mainsnak: this.mainsnak,
			qualifiers: this.qualifiers,
			'qualifiers-order': ( this.qualifiersOrder ?? null ) !== null && this.qualifiersOrder.length > 0 ? this.qualifiersOrder : undefined,
			rank: this.rank ?? undefined,
			references: this.references,
			type: 'statement'
		};
	}

	/**
	 * @return {Promise<Object.<string, Array<WikidataPain.Snak>>>}
	 */
	async getQualifiers() {
		await this.#assertLoaded( false );

		if ( this.qualifiers === null ) {
			return {};
		} else {
			return this.qualifiers;
		}
	}

	/**
	 * @param {string} property
	 * @return {Promise<Array<WikidataPain.Snak>>}
	 */
	async getQualifierSnaks( property ) {
		await this.#assertLoaded( false );

		if ( this.qualifiers === null ) {
			this.qualifiers = {};
			this.qualifiers[ property ] = [];
		} else if ( ( this.qualifiers[ property ] ?? null ) === null ) {
			this.qualifiers[ property ] = [];
		}
		return this.qualifiers[ property ];
	}

	/**
	 * @return {Promise<WikidataPain.Rank>}
	 */
	async getRank() {
		await this.#assertLoaded( false );

		return this.rank;
	}

	/**
	 * @return {Promise<Array<WikidataPain.Reference>>}
	 */
	async getReferences() {
		await this.#assertLoaded( true );

		if ( this.references === undefined ) {
			return [];
		} else {
			return this.references;
		}
	}

	/**
	 * Explicitly loads the statement.
	 * Typically, this method doesn't need to be called, but can be used to improve the efficiency of scripts.
	 *
	 * @param {boolean} [requestReferences=false]
	 * @return {Promise<void>}
	 */
	async load( requestReferences = false ) {
		const raw = await this.#loadRaw( requestReferences );

		if ( !this.#loaded ) {
			this.mainsnak = raw.mainsnak ?? null;
			this.rank = raw.rank ?? null;
			this.type = raw.type ?? null;
			this.qualifiers = {};
			if ( ( raw.qualifiers ?? null ) !== null ) {
				for ( const key of Object.keys( raw.qualifiers ) ) {
					this.qualifiers[ key ] = raw.qualifiers[ key ].map( ( rawQualifier ) => {
						return WikidataPain.Snak.fromRaw( rawQualifier );
					} );
				}
			}
			this.qualifiersOrder = raw[ 'qualifiers-order' ] ?? [];
		}

		if ( requestReferences && !this.#referencesLoaded ) {
			this.references = [];
			if ( ( raw.references ?? null ) !== null ) {
				this.references = raw.references.map( ( reference ) => {
					return WikidataPain.Reference.fromRaw( reference );
				} );
			}
		} else if ( !requestReferences && !this.#loaded ) {
			this.references = [];
		}

		this.#loaded = true;
		this.#referencesLoaded = this.#referencesLoaded || requestReferences;
	}

	/**
	 * @param {WikidataPain.Statement} other
	 * @param {Object} [config]
	 * @param {WikidataPain.ReferenceMatchingCriterium} [config.referenceRefinableCriterium=WikidataPain.ReferenceMatchingCriterium.overlap]
	 * @return {Promise<boolean>} Whether a change occured.
	 */
	async mergeFrom( other, { referenceRefinableCriterium = null } = {} ) {
		await Promise.all( [ this.#assertLoaded( true ), other.#assertLoaded( true ) ] );

		// Value
		// FIXME: Implement

		// Qualifiers
		let qualifierChangeOccured = false;
		for ( const [ qualifier, snaks ] of Object.entries( other.qualifiers ) ) {
			if ( this.qualifiers[ qualifier ] === undefined ) {
				this.qualifiers[ qualifier ] = [];
			}

			for ( const snak of snaks ) {
				const changeOccured = await this.mergeQualifierSnak( qualifier, snak );
				qualifierChangeOccured = qualifierChangeOccured || changeOccured;
			}
		}

		// References
		let referenceChangeOccured = false;
		for ( const reference of other.references ) {
			const changeOccured = await this.mergeReference( reference, {
				referenceRefinableCriterium: referenceRefinableCriterium
			} );
			referenceChangeOccured = referenceChangeOccured || changeOccured;
		}

		return qualifierChangeOccured || referenceChangeOccured;
	}

	/**
	 * @param {string} qualifier
	 * @param {WikidataPain.Snak} newSnak
	 * @return {Promise<boolean>} Whether a change occured.
	 */
	async mergeQualifierSnak( qualifier, newSnak ) {
		await this.#assertLoaded( false );

		if ( this.qualifiers[ qualifier ] === undefined || this.qualifiers[ qualifier ].length === 0 ) {
			this.qualifiers[ qualifier ] = [ newSnak ];
			return true;
		} else {
			// Find coarser or equal snaks
			const matches = this.qualifiers[ qualifier ].filter( ( originalSnak ) => {
				const precisionComparison = originalSnak.comparePrecision( newSnak );
				return precisionComparison === WikidataPain.PrecisionComparison.EqualValue || precisionComparison === WikidataPain.PrecisionComparison.LessPrecise;
			} );

			let changeOccured = false;

			if ( matches.length === 1 ) {
				const originalSnak = matches[ 0 ];
				if ( originalSnak.comparePrecision( newSnak ) === WikidataPain.PrecisionComparison.EqualValue ) {
					// Snak already exists, nothing to do
				} else {
					// Refine existing Snak
					matches[ 0 ].merge( newSnak );
					changeOccured = true;
				}
			} else if ( matches.length === 0 ) {
				// Append new Snak
				this.qualifiers[ qualifier ].push( newSnak );
				changeOccured = true;
			} else {
				throw new Error( `Found ${matches.length} snaks that are equal or can be refined.` );
			}

			return changeOccured;
		}
	}

	/**
	 * @param {WikidataPain.Reference} newReference
	 * @param {Object} [config]
	 * @param {WikidataPain.ReferenceMatchingCriterium} [config.referenceRefinableCriterium=WikidataPain.ReferenceMatchingCriterium.overlap]
	 * @return {Promise<boolean>} Whether changes occured.
	 */
	async mergeReference( newReference, { referenceRefinableCriterium = null } = {} ) {
		await this.#assertLoaded( true );

		let matches;
		switch ( referenceRefinableCriterium ?? WikidataPain.ReferenceMatchingCriterium.overlap ) {
			case WikidataPain.ReferenceMatchingCriterium.overlap:
				matches = this.references.filter( ( originalReference ) => {
					return originalReference.overlaps( newReference );
				} );
				break;
			case WikidataPain.ReferenceMatchingCriterium.precision:
				// Find coarser or equal references
				matches = this.references.filter( ( originalReference ) => {
					const precisionComparison = originalReference.comparePrecision( newReference );
					return precisionComparison === WikidataPain.PrecisionComparison.EqualValue || precisionComparison === WikidataPain.PrecisionComparison.LessPrecise;
				} );
				break;
			default:
				throw new Error( `Illegal referenceMatchingCriterium: ${referenceRefinableCriterium}` );
		}

		if ( matches.length === 1 ) {
			const originalReference = matches[ 0 ];
			if ( originalReference.comparePrecision( newReference ) === WikidataPain.PrecisionComparison.EqualValue ) {
				// Snak already exists, nothing to do
				console.debug( '[WikidataPain] Skipping reference as it already exists' );
				return false;
			} else {
				// Refine existing Snak
				console.debug( '[WikidataPain] Refine existing Snak' );
				return matches[ 0 ].merge( newReference );
			}
		} else if ( matches.length === 0 ) {
			// Append new Reference
			console.debug( '[WikidataPain] Add new reference' );
			this.references.push( newReference );
			return true;
		} else {
			throw new Error( `Found ${matches.length} snaks that are equal or can be refined.` );
		}
	}

	/**
	 * 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 {SnakTransformer?} [mainSnakTransformer] Function to apply to the claim's value which's result will be used as the newly created statement's claim.
	 * @param {StatementTransformer?} [statementTransformer] Function to apply to the statement which's result will be used.
	 * @param {Object} [config]
	 * @param {string} [config.editgroup=null]
	 * @param {string} [config.summary=null]
	 * @return {Promise<void>}
	 */
	async moveToPropertyInstant( targetProperty, mainSnakTransformer = null, statementTransformer = null, config = {} ) {
		const entityId = WikidataPain.Util.entityIdFromClaimId( this.id );

		const newStatement = WikidataPain.Statement.fromRawNew( entityId, await this.getObject() );

		// Edit property
		newStatement.mainsnak.property = targetProperty;

		// Apply mainSnakTransformer
		if ( mainSnakTransformer !== null ) {
			newStatement.mainsnak = await mainSnakTransformer( newStatement.mainsnak );
		}

		// Apply statementTransformer
		if ( statementTransformer !== null ) {
			newStatement = await statementTransformer( newStatement );
		}

		const data = {
			claims: [
				{
					id: this.id,
					remove: ''
				},
				newStatement
			]
		};

		await WikidataPain.postCsrf( WikidataPain.Util.commonParameters( {
			action: 'wbeditentity',
			data: JSON.stringify( data ),
			id: entityId
		}, config ) );
	}

	/**
	 * @param {boolean} [removeReasonForPreferred=false]
	 * @param {Object} [config]
	 * @param {string} [config.editgroup=null]
	 * @param {string} [config.summary=null]
	 * @return {Promise<Array<WikidataPain.Statement>>} Other statements sharing the same property that have been edited.
	 */
	async preferOnlyInstant( removeReasonForPreferred = false, { editgroup = null, summary = null } = {} ) {
		// Set own rank
		if ( this.new ) {
			await this.setRank( WikidataPain.Rank.Preferred );
		} else {
			await this.setRankInstant( WikidataPain.Rank.Preferred, null, {
				editgroup: editgroup,
				summary: summary
			} );
		}

		// Demote other statements
		const otherStatements = ( await WikidataPain.Statement.fromEntityCollapsed( this.getEntity(), ( await this.getMainsnak() ).property ) ).filter( ( otherStatement ) => {
			return otherStatement.id !== this.id;
		} );

		const edited = [];
		for ( const otherStatement of otherStatements ) {
			let editNeeded = false;
			if ( ( await otherStatement.getRank() ) === WikidataPain.Rank.Preferred ) {
				// Set rank
				otherStatement.setRank( WikidataPain.Rank.Normal );
				editNeeded = true;
			}

			if ( removeReasonForPreferred ) {
				// Remove reason for preferred rank
				const removed = await otherStatement.qualifierRemove( WikidataPain.PROPERTY_REASON_FOR_PREFERENCE );
				editNeeded = editNeeded || removed > 0;
			}

			if ( editNeeded ) {
				await otherStatement.commit( {
					editgroup: editgroup,
					summary: summary
				} );
				edited.push( otherStatement );
			}
		}

		return edited;
	}

	/**
	 * @param {WikidataPain.Snak} newQualifier
	 * @param {Object} [config]
	 * @param {('delete'|'fail')} [config.onAdditionalQualifierValues]
	 * @return {Promise<void>}
	 */
	async qualifierAddOrRefine( newQualifier, { onAdditionalQualifierValues = null } = {} ) {
		const property = newQualifier.property;
		let qualifierSnaks = await this.getQualifierSnaks( 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( ( qualifier ) => {
				return [ WikidataPain.PrecisionComparison.IncomparableValue, WikidataPain.PrecisionComparison.MorePrecise ].includes( qualifier.comparePrecision( newQualifier ) );
			} );

			if ( incompatibleQualifiers.length > 0 ) {
				switch ( onAdditionalQualifierValues ) {
					case 'delete':
						this.qualifiers[ property ] = [];
						qualifierSnaks = null;
						break;
					case 'fail':
						throw new Error( 'Additional qualifiers found' );
					default:
						throw new Error();
				}
			}
		}

		if ( qualifierSnaks === null || qualifierSnaks.length === 0 ) {
			// Add: If there are no qualifier snaks yet, just add new qualifier.
			this.qualifiers[ property ] = [ newQualifier ];
			return;
		}

		// Check, if qualifier snak already exists
		const equalQualifiers = qualifierSnaks.filter( ( qualifier ) => {
			return qualifier.comparePrecision( newQualifier ) === WikidataPain.PrecisionComparison.EqualValue;
		} );

		if ( equalQualifiers.length > 0 ) {
			// Exists already
			return;
		}

		// Check, if there are refineable qualifier snaks
		const refinableQualifiers = qualifierSnaks.filter( ( qualifier ) => {
			return 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 );
			}
		} else {
			// Add new qualifier
			this.qualifiers[ property ].push( newQualifier );
		}
	}

	/**
	 * @param {WikidataPain.Snak} snak
	 * @return {Promise<void>}
	 */
	async qualifierAppend( snak ) {
		await this.#assertLoaded( false );

		const property = snak.property;

		if ( !this.qualifiersOrder.includes( property ) ) {
			this.qualifiersOrder.push( property );
		}

		if ( this.qualifiers[ property ] === undefined ) {
			this.qualifiers[ property ] = [];
		}

		this.qualifiers[ property ].push( snak );
	}

	/**
	 * @param {string} propertyOrSnak (1) Name or the qualifier's property to remove. (2) Snak to remove.
	 * @param {WikidataPain.Snak} snak (1) Snak of the qualifier to remove. Set to `null` to delete regardless of snak. (2) Leave `null`.
	 * @return {Promise<number>} Number of qualifiers that have been removed-
	 */
	async qualifierRemove( propertyOrSnak, snak = null ) {
		await this.#assertLoaded( false );

		/** @type {string} */
		let property = null;
		/** @type {WikidataPain.Snak} */
		let actualSnak = null;
		if ( typeof propertyOrSnak === 'string' ) {
			actualSnak = snak;
			property = propertyOrSnak;
		} else {
			actualSnak = propertyOrSnak;
			property = actualSnak.property;
		}

		if ( this.qualifiers[ property ] === undefined ) {
			// Nothing to remove
			throw new Error( 'Statement has no qualifiers' );
		}

		let deletedQualifiers;
		if ( actualSnak === null ) {
			deletedQualifiers = this.qualifiers[ property ].length;
			delete this.qualifiers[ property ];
		} else {
			this.qualifiers[ property ] = this.qualifiers[ property ].filter( ( value ) => {
				const isEqual = value.equals( actualSnak );

				if ( !isEqual ) {
					deletedQualifiers++;
				}

				return !isEqual;
			} );

			if ( this.qualifiers[ property ].length === 0 ) {
				delete this.qualifiers[ property ];
			}
		}

		if ( deletedQualifiers === 0 ) {
			throw new Error( 'No qualifiers to remove found' );
		}

		// Remove from order, if needed
		if ( this.qualifiers[ property ] === undefined ) {
			const qualifierOrder = this.qualifiersOrder.indexOf( property );
			if ( qualifierOrder > -1 ) {
				this.qualifiersOrder.splice( qualifierOrder, 1 );
			}
		}

		return deletedQualifiers;
	}

	/**
	 * Loads the original statement and adds qualifiers and references of this statement to it.
	 *
	 * @param {boolean} allowCreate
	 * @param {Object} [config]
	 * @param {string} [config.editgroup=null]
	 * @param {WikidataPain.ComparisonMethod} [config.qualifierComparisonMethod]
	 * @param {QualifierMatcher} [config.qualifierMatcher]
	 * @param {WikidataPain.ReferenceMatchingCriterium} [config.referenceRefinableCriterium=WikidataPain.ReferenceMatchingCriterium.overlap]
	 * @param {string} [config.summary=null]
	 * @return {Promise<Object>}
	 */
	async refine( allowCreate, { editgroup = null, qualifierComparisonMethod = null, qualifierMatcher = null, referenceRefinableCriterium = null, summary = null } = {} ) {
		let originalStatement;

		if ( this.new ) {
			const refinables = await this.findPotentiallyRefinable( {
				qualifierMatcher: qualifierMatcher ?? WikidataPain.QualifierMatcherFactory.candidateSubset( {
					comparisonMethod: qualifierComparisonMethod
				} )
			} );

			if ( refinables.length === 0 ) {
				if ( allowCreate ) {
					originalStatement = null;
				} else {
					throw new Error( 'No refinable statement found.' );
				}
			} else if ( refinables.length > 1 ) {
				throw new Error( `Illegal number of refinable statements found: ${refinables.length}` );
			} else {
				originalStatement = refinables[ 0 ];
			}
		} else {
			originalStatement = WikidataPain.Statement.fromClaimId( this.id );
		}

		if ( originalStatement !== null ) {
			// Refine
			await originalStatement.#assertLoaded( true );

			const changeOccured = originalStatement.mergeFrom( this, {
				referenceRefinableCriterium: referenceRefinableCriterium
			} );

			if ( await changeOccured ) {
				console.debug( `[WikidataPain] Refining statement for ${WikidataPain.Util.entityIdFromClaimId( this.id )}` );
				return await originalStatement.setClaim( { editgroup: editgroup, summary: summary } );
			} else {
				// No changes to push, nothing to do.
				console.debug( `[WikidataPain] Skipping ${WikidataPain.Util.entityIdFromClaimId( this.id )}` );
				return null;
			}
		} else {
			// Create
			console.debug( `[WikidataPain] Creating new statement for ${WikidataPain.Util.entityIdFromClaimId( this.id )}` );
			return await this.commit( { editgroup: editgroup, summary: summary } );
		}
	}

	/**
	 * @param {string} hash
	 * @return {Promise<boolean>} Whether a reference has been removed.
	 */
	async removeReferenceByHash( hash ) {
		await this.#assertLoaded( true );

		let matches = 0;
		this.references = this.references.filter( ( reference ) => {
			if ( reference.hash === hash ) {
				matches++;
				return false;
			}

			return true;
		} );

		if ( matches > 1 ) {
			throw new Error( `Found multiple references witth hash ${hash}` );
		}

		return matches > 0;
	}

	/**
	 * @param {Object} [config]
	 * @param {?string} [config.editgroup]
	 * @param {?string} [config.summary]
	 * @return {Promise<Object>}
	 */
	async setClaim( { editgroup = null, summary = null } = {} ) {
		const claim = await this.getObject( true );

		return await WikidataPain.postCsrf( {
			action: 'wbsetclaim',
			claim: JSON.stringify( claim ),
			format: 'json',
			maxlag: WikidataPain.MAXLAG,
			summary: WikidataPain.Util.summary( editgroup, summary )
		} );
	}

	/**
	 * @param {!WikidataPain.Rank} newRank
	 * @param {?WikidataPain.Rank} [oldRank=null]
	 * @return {Promise<void>}
	 */
	async setRank( newRank, oldRank = null ) {
		await this.#assertLoaded( false );

		if ( oldRank !== null && this.rank !== oldRank ) {
			throw new Error( 'Rank differs from expected value' );
		}
		this.rank = newRank;
	}

	/**
	 * @param {!WikidataPain.Rank} newRank
	 * @param {?WikidataPain.Rank} [oldRank=null]
	 * @param {Object} [config]
	 * @param {?string} [config.editgroup]
	 * @param {?string} [config.summary]
	 * @return {Promise<void>}
	 */
	async setRankInstant( newRank, oldRank = null, { editgroup = null, summary = null } = {} ) {
		await this.#assertLoaded( false );

		if ( oldRank !== null && this.rank !== oldRank ) {
			throw new Error( 'Rank differs from expected value' );
		}
		this.rank = newRank;
		this.commit( {
			editgroup: editgroup,
			summary: summary
		} );
	}

	/**
	 * Sets this statement's value.
	 * The DataValue's snaktype is set to `value`, if the provided `newValue` was neither a {@link WikidataPain.DataValue} nor a {@link WikidataPain.Snak}.
	 * Must be committed by calling {@link WikidataPain.Statement#commit}.
	 *
	 * @param {WikidataPain.DataValue|WikidataPain.Snak|any} newValue New value
	 * @return {Promise<void>}
	 */
	async setValue( newValue ) {
		await this.#assertLoaded( true );

		if ( newValue instanceof WikidataPain.DataValue ) {
			this.mainsnak.datavalue = newValue;
		} else if ( newValue instanceof WikidataPain.Snak ) {
			this.mainsnak.datavalue = newValue.datavalue;
		} else {
			this.mainsnak.datavalue.value = newValue;
			this.mainsnak.datavalue.type = WikidataPain.Snak.ValueType.Value;
		}
	}

	/**
	 * Sets this statement's value and the snaktype to `value` using `wbsetclaimvalue`.
	 * Is committed instantly and may not be chained.
	 *
	 * @param {any} newValue
	 * @param {Object?} [options]
	 * @return {Promise<void>}
	 */
	async setValueInstant( newValue, options = {} ) {
		let value;
		if ( newValue instanceof WikidataPain.DataValue ) {
			value = newValue.value;
		} else if ( newValue instanceof WikidataPain.Snak ) {
			value = newValue.datavalue.value;
		} else {
			value = newValue;
		}

		return await WikidataPain.postCsrf( WikidataPain.Util.commonParameters( {
			action: 'wbsetclaimvalue',
			claim: this.id,
			snaktype: WikidataPain.Snak.ValueType.Value,
			value: ( typeof value === 'string' ? value : JSON.stringify( value ) )
		}, options ) );
	}

	/**
	 * Sets this statement's mainsnak's type.
	 * Must be committed by calling {@link WikidataPain.Statement#commit}.
	 *
	 * @param {WikidataPain.Snak.ValueType} snakType Snak type to set. May not be `Value`.
	 * @return {Promise<void>}
	 */
	async setValueSnakType( snakType ) {
		await this.#assertLoaded( true );

		if ( snakType === WikidataPain.Snak.ValueType.Value ) {
			throw new Error( 'Use Snak#setValue for assigning a concrete value.' );
		}

		this.mainsnak.datavalue.type = snakType;
	}

	/**
	 * Sets this statement's mainsnak's type.
	 * Is committed instantly and may not be chained.
	 *
	 * @param {WikidataPain.Snak.ValueType} snaktype Snak type to set. May not be `Value`.
	 * @param {Object?} [options]
	 * @return {Promise<void>}
	 */
	async setValueSnakTypeInstant( snaktype, options = {} ) {
		if ( snaktype === WikidataPain.Snak.ValueType.Value ) {
			throw new Error( 'Statement#setValueSnakTypeInstant may not be used for the type `value`. Use Statement#setValueInstant instead.' );
		}

		return await WikidataPain.postCsrf( WikidataPain.Util.commonParameters( {
			action: 'wbsetclaimvalue',
			claim: this.id,
			snaktype: snaktype
		}, options ) );
	}

	/**
	 * Applies `mainSnakTransformer` to the statement's claim's value and sets it's output as the new claim.
	 * Must be committed by calling {@link WikidataPain.Statement.commit}.
	 *
	 * @param {SnakTransformer} mainSnakTransformer Function to apply to the claim's value which's result with replace the claim's current value.
	 * @return {Promise<void>}
	 */
	async transformMainSnak( mainSnakTransformer ) {
		await this.#assertLoaded( true );

		this.mainsnak = await mainSnakTransformer( this.mainsnak );
	}

	/**
	 * @param {boolean} requestReferences
	 * @return {Promise<void>}
	 */
	async #assertLoaded( requestReferences ) {
		if ( this.#loaded && ( !requestReferences || this.#referencesLoaded ) ) {
			// Loaded
			return;
		}

		await this.load( requestReferences );
	}

	async #loadRaw( requestReferences ) {
		const props = [];
		if ( requestReferences === true ) {
			props.push( 'references' );
		}

		const data = await WikidataPain.get( {
			action: 'wbgetclaims',
			claim: this.id,
			props: props.join( '|' )
		} );

		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 );

			return propertyStatements[ 0 ];
		} else {
			throw new Error( data );
		}
	}
};

/**
 * @class
 */
WikidataPain.Util = class {
	/**
	 * @param {Object} parameters
	 * @param {Object} [config]
	 * @param {string} [config.editgroup]
	 * @param {string} [config.summary]
	 * @return {Object}
	 */
	static commonParameters( parameters, { editgroup = null, summary = null } = {} ) {
		// Format
		if ( parameters.format === undefined ) {
			parameters.format = 'json';
		}

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

		// Summary
		parameters.summary = WikidataPain.Util.summary( editgroup, summary );

		return parameters;
	}

	/**
	 * @param {Object.<string, Array<WikidataPain.Snak>>} leftSnakMap
	 * @param {Object.<string, Array<WikidataPain.Snak>>} rightSnakMap
	 * @return {WikidataPain.PrecisionComparison}
	 */
	static comparePropertyValues( leftSnakMap, rightSnakMap ) {
		const left = compare( leftSnakMap, rightSnakMap );
		const right = compare( rightSnakMap, leftSnakMap );

		const comparisons = WikidataPain.Util.makeArrayUnique( [ left, WikidataPain.Util.invertPrecision( right ) ] );

		if ( comparisons.length === 1 ) {
			return comparisons[ 0 ];
		}

		comparisons.filter( ( precision ) => {
			return precision !== WikidataPain.PrecisionComparison.EqualValue;
		} );

		if ( comparisons.length === 1 ) {
			return comparisons[ 0 ];
		} else {
			return WikidataPain.PrecisionComparison.IncomparableValue;
		}

		/**
		 * @param {Array<WikidataPain.PrecisionComparison>} precisions
		 * @return {WikidataPain.PrecisionComparison}
		 */
		function accumulatePrecisions( precisions ) {
			const uniquePrecisions = WikidataPain.Util.makeArrayUnique( precisions );

			if ( uniquePrecisions.length === 0 ) {
				// Found no matches
				throw new Error( 'List most contain at least one precision' );
			} else if ( uniquePrecisions.length === 1 ) {
				// Only one match, use its precision
				return uniquePrecisions[ 0 ];
			} else if ( uniquePrecisions.length === 2 && uniquePrecisions.includes( WikidataPain.PrecisionComparison.EqualValue ) ) {
				// Equal + 𝑥 = 𝑥
				return uniquePrecisions.filter( ( precision ) => {
					return precision !== WikidataPain.PrecisionComparison.EqualValue;
				} )[ 0 ];
			} else {
				// Equal + Less + Greater = Incomparable
				return WikidataPain.PrecisionComparison.IncomparableValue;
			}
		}

		/**
		 * @param {Object.<string, Array<WikidataPain.Snak>>} leftSnakMap_
		 * @param {Object.<string, Array<WikidataPain.Snak>>} rightSnakMap_
		 * @return {WikidataPain.PrecisionComparison}
		 */
		function compare( leftSnakMap_, rightSnakMap_ ) {
			const precisionsPerSnak = Object.fromEntries( Object.entries( leftSnakMap_ )
				.map( ( [ property, leftSnaks ] ) => {
					return [ property, leftSnaks.map( ( leftSnak ) => {
						/** @type {Array<WikidataPain.Snak>} */
						const rightSnaks = rightSnakMap_[ property ];

						if ( rightSnakMap_[ property ] === undefined ) {
							return WikidataPain.PrecisionComparison.MorePrecise;
						}

						const res = WikidataPain.Util.makeArrayUnique( rightSnaks.map( ( rightSnak ) => {
							return leftSnak.comparePrecision( rightSnak );
						} ).filter( ( precision ) => {
							// Ignore incomparable snaks (unrelated values)
							return precision !== WikidataPain.PrecisionComparison.IncomparableValue;
						} ) );

						if ( res.length === 0 ) {
							// Found no matches, left has more qualifiers
							return WikidataPain.PrecisionComparison.MorePrecise;
						} else {
							return accumulatePrecisions( res );
						}
					} ) ];
				} ) );

			return accumulatePrecisions( Object.values( precisionsPerSnak ).flat() );
		}
	}

	/**
	 * 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} date
	 * @return {DissectedTime}
	 */
	static dissectQsTimestamp( 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.day ),
			month: parseInt( match.groups.month ),
			precision: parseInt( match.groups.precision ),
			time: match.groups.time,
			year: parseInt( match.groups.year )
		};
	}

	/**
	 * @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 async filterAsync( array, callbackFn ) {
		const predicatedArray = await Promise.all( array.map( callbackFn ) );
		return array.filter( ( value, index ) => {
			return predicatedArray[ index ];
		} );
	}

	/**
	 * Try to format a English/German date with day-precision as a QuickStatement date.
	 *
	 * @param {string} input
	 * @return {string}
	 */
	static formatDate( input ) {
		const monthsTextRegexString = 'Jan(uary?)?|Feb(ruary?)?|M(ar(ch)?|ärz?)|Apr(il)?|Ma[iy]|Jun[ei]?|Jul[iy]?|Aug(ust)?|Sep(tember)?|O[ck]t(ober)?|Nov(ember)?|De[cz](ember)?';

		// 10+11
		input = input.replace( new RegExp( `^((?<day>[0-3]?\\d)(\\. ?| ))?(?<month>${monthsTextRegexString}|0?[1-9]|1[0-2])(\\. ?| )(?<year>\\d{4})$` ), ( ...d ) => {
			const matches = d[ d.length - 1 ];
			const precision = matches.day === undefined ? 10 : 11;

			const year = matches.year;
			const month = String( getMonth( matches.month ) ).padStart( 2, '0' );
			const day = precision < 11 ? '00' : matches.day.padStart( 2, '0' );

			return `+${year}-${month}-${day}T00:00:00Z/${precision}`;
		} );

		// Year (9)
		input = input.replace( /^(?<year>(1[0-9]|20)\d{2})$/, ( ...d ) => {
			const matches = d[ d.length - 1 ];
			return `+${matches.year}-00-00T00:00:00Z/9`;
		} );

		return input;

		function getMonth( textualMonth ) {
			if ( !isNaN( parseInt( textualMonth ) ) ) {
				const month = parseInt( textualMonth );
				if ( Number.isInteger( month ) && month >= 1 && month <= 12 ) {
					return month;
				} else {
					throw new Error( `Invalid month ${textualMonth}` );
				}
			}

			switch ( textualMonth ) {
				case 'Jan':
				case 'Januar':
				case 'January':
					return 1;
				case 'Feb':
				case 'Februar':
				case 'February':
					return 2;
				case 'Mär':
				case 'März':
				case 'Mar':
				case 'March':
					return 3;
				case 'Apr':
				case 'April':
					return 4;
				case 'Mai':
				case 'May':
					return 5;
				case 'Jun':
				case 'Juni':
				case 'June':
					return 6;
				case 'Jul':
				case 'Juli':
				case 'July':
					return 7;
				case 'Aug':
				case 'August':
					return 8;
				case 'Sep':
				case 'September':
					return 9;
				case 'Oct':
				case 'October':
				case 'Okt':
				case 'Oktober':
					return 10;
				case 'Nov':
				case 'November':
					return 11;
				case 'Dec':
				case 'December':
				case 'Dez':
				case 'Dezember':
					return 12;
				default:
					throw new Error( `Unknown month ${textualMonth}` );
			}
		}
	}

	/**
	 * Format tabular data for use with QuickStatements or Rsolver.
	 *
	 * @param {string} input
	 * @return {string}
	 */
	static formatTabular( input ) {
		return input.split( '\n' ).map( ( line ) => {
			return line.trim().split( '\t' ).map( ( item ) => {
				item = item.trim();
				if ( item.startsWith( '$' ) ) {
					return `$${WikidataPain.Util.formatDate( item.slice( 1 ) )}`;
				} else {
					return WikidataPain.Util.formatDate( item );
				}
			} ).join( '\t' );
		} ).join( '\n' );
	}

	/**
	 * Reverses the "direction" of a precision comparison.
	 *
	 * @param {WikidataPain.PrecisionComparison} precision
	 * @return {WikidataPain.PrecisionComparison}
	 */
	static invertPrecision( precision ) {
		if ( precision === WikidataPain.PrecisionComparison.MorePrecise ) {
			return WikidataPain.PrecisionComparison.LessPrecise;
		} else if ( precision === WikidataPain.PrecisionComparison.LessPrecise ) {
			return WikidataPain.PrecisionComparison.MorePrecise;
		} else {
			return precision;
		}
	}

	/**
	 * @template T
	 * @param {Array<T>} array
	 * @return {Array<T>}
	 */
	static makeArrayUnique( array ) {
		return array.filter( ( precision, index, list ) => {
			return list.indexOf( precision ) === index;
		} );
	}

	/**
	 * Normalises claim IDs as returned by WDQS.
	 *
	 * @param {string} claimId
	 * @return {string}
	 */
	static normaliseClaimId( claimId ) {
		claimId = claimId.trim().replace( /http:\/\/www\.wikidata\.org\/entity\/statement\//g, '' );
		if ( /^Q\d+\$[a-z\d]{8}-(?:[a-z\d]{4}-){3}[a-z\d]{12}$/i.test( claimId ) ) {
			return claimId;
		} else if ( /^http:\/\/www\.wikidata\.org\/entity\/statement\/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;
			}
		}
	}

	/**
	 * Convert QuickStatement-line to Statement.
	 *
	 * @param {Array<string>} qsLine
	 * @return {Promise<WikidataPain.Statement>}
	 */
	static async quickStatementsLineToStatement( qsLine ) {
		if ( qsLine.length < 3 || qsLine.length % 2 !== 1 ) {
			console.error( qsLine );
			throw new Error( 'Invalid QuickStatements-line' );
		}

		const entity = qsLine[ 0 ];
		/** @type {WikidataPain.Snak} */
		let mainsnak = null;
		const qualifiers = [];
		/** @type {Array<WikidataPain.Snak>} */
		const sourceSnaks = [];

		for ( let pair = 0; 1 + ( pair * 2 ) < qsLine.length; pair++ ) {
			const thisFragment = qsLine[ 1 + ( pair * 2 ) ];
			const nextFragment = qsLine[ 1 + ( pair * 2 ) + 1 ];

			if ( thisFragment.startsWith( 'P' ) ) {
				if ( pair === 0 ) {
					// Mainsnak
					mainsnak = WikidataPain.Snak.fromQuickStatements( thisFragment, nextFragment );
				} else {
					// Qualifier
					qualifiers.push( WikidataPain.Snak.fromQuickStatements( thisFragment, nextFragment ) );
				}
			} else if ( thisFragment.startsWith( 'S' ) ) {
				sourceSnaks.push( WikidataPain.Snak.fromQuickStatements( thisFragment.replace( /^S/, 'P' ), nextFragment ) );
			} else {
				console.error( thisFragment );
				throw new Error( 'Unexpected QuickStatements-fragment' );
			}
		}

		const statement = WikidataPain.Statement.fromBlank( entity, mainsnak );
		for ( const qualifier of qualifiers ) {
			statement.qualifierAppend( qualifier );
		}

		// References
		if ( sourceSnaks.length > 0 ) {
			const reference = WikidataPain.Reference.fromSnaks( sourceSnaks );
			await statement.addReference( reference );
		}

		return statement;
	}

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

	/**
	 * Returns a random ID which can be used for 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, ( c ) => {
			const r = Math.random() * 16 | 0;
			const v = c === 'x' ? r : ( r & 0x3 | 0x8 );
			return v.toString( 16 ).toUpperCase();
		} );
	}

	/**
	 * Waits the given number of miliseconds using `setTimeout`, then resolves the promise.
	 *
	 * @param {number} ms
	 * @return {Promise<void>}
	 */
	static sleep( ms ) {
		return new Promise( ( resolve ) => {
			setTimeout( resolve, ms );
		} );
	}

	/**
	 * @param {string} editgroup
	 * @param {string} summary
	 * @return {string}
	 */
	static summary( editgroup = null, summary = null ) {
		if ( editgroup == null && summary == null ) {
			return null;
		} else if ( editgroup != null && summary != null ) {
			return `${summary} ([[:toollabs:editgroups/b/CB/${editgroup}|details]])`;
		} else if ( editgroup != null ) {
			return `([[:toollabs:editgroups/b/CB/${editgroup}|details]])`;
		} else {
			return summary;
		}
	}
};

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