( function () {

	/**
	 * Abstract class for datetime formatters.
	 *
	 * @class
	 * @classdesc Provides various methods needed for formatting dates and times.
	 * @abstract
	 * @mixes OO.EventEmitter
	 *
	 * @constructor
	 * @param {Object} [config] Configuration options
	 * @param {string} [config.format='@default'] May be a key from the {@link #static-formats static formats},
	 *  or a format specification as defined by {@link #method-parseFieldSpec parseFieldSpec}
	 *  and {@link #method-getFieldForTag getFieldForTag}.
	 * @param {boolean} [config.local=false] Whether dates are local time or UTC
	 * @param {string[]} [config.fullZones] Time zone indicators. Array of 2 strings, for
	 *  UTC and local time.
	 * @param {string[]} [config.shortZones] Abbreviated time zone indicators. Array of 2
	 *  strings, for UTC and local time.
	 * @param {Date} [config.defaultDate] Default date, for filling unspecified components.
	 *  Defaults to the current date and time (with 0 milliseconds).
	 */
	mw.widgets.datetime.DateTimeFormatter = function MwWidgetsDatetimeDateTimeFormatter( config ) {
		this.constructor.static.setupDefaults();

		config = $.extend( {
			format: '@default',
			local: false,
			fullZones: this.constructor.static.fullZones,
			shortZones: this.constructor.static.shortZones
		}, config );

		// Mixin constructors
		OO.EventEmitter.call( this );

		// Properties
		if ( this.constructor.static.formats[ config.format ] ) {
			this.format = this.constructor.static.formats[ config.format ];
		} else {
			this.format = config.format;
		}
		this.local = !!config.local;
		this.fullZones = config.fullZones;
		this.shortZones = config.shortZones;
		if ( config.defaultDate instanceof Date ) {
			this.defaultDate = config.defaultDate;
		} else {
			this.defaultDate = new Date();
			if ( this.local ) {
				this.defaultDate.setMilliseconds( 0 );
			} else {
				this.defaultDate.setUTCMilliseconds( 0 );
			}
		}
	};

	/* Setup */

	OO.initClass( mw.widgets.datetime.DateTimeFormatter );
	OO.mixinClass( mw.widgets.datetime.DateTimeFormatter, OO.EventEmitter );

	/* Static */

	/**
	 * Default format specifications. See the {@link #format format} parameter.
	 *
	 * @static
	 * @inheritable
	 * @type {Object}
	 * @name mw.widgets.datetime.DateTimeFormatter.formats
	 */
	mw.widgets.datetime.DateTimeFormatter.static.formats = {};

	/**
	 * Default time zone indicators.
	 *
	 * @static
	 * @inheritable
	 * @type {string[]}
	 * @name mw.widgets.datetime.DateTimeFormatter.fullZones
	 */
	mw.widgets.datetime.DateTimeFormatter.static.fullZones = null;

	/**
	 * Default abbreviated time zone indicators.
	 *
	 * @static
	 * @inheritable
	 * @type {string[]}
	 * @name mw.widgets.datetime.DateTimeFormatter.shortZones
	 */
	mw.widgets.datetime.DateTimeFormatter.static.shortZones = null;

	mw.widgets.datetime.DateTimeFormatter.static.setupDefaults = function () {
		if ( !this.fullZones ) {
			this.fullZones = [
				mw.msg( 'timezone-utc' ),
				mw.msg( 'timezone-local' )
			];
		}
		if ( !this.shortZones ) {
			this.shortZones = [
				'Z',
				this.fullZones[ 1 ].slice( 0, 1 ).toUpperCase()
			];
			if ( this.shortZones[ 1 ] === 'Z' ) {
				this.shortZones[ 1 ] = 'L';
			}
		}
	};

	/* Events */

	/**
	 * A `local` event is emitted when the 'local' flag is changed.
	 *
	 * @event mw.widgets.datetime.DateTimeFormatter.local
	 * @param {boolean} local Whether dates are local time
	 */

	/* Methods */

	/**
	 * Whether dates are in local time or UTC.
	 *
	 * @return {boolean} True if local time
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.getLocal = function () {
		return this.local;
	};

	/**
	 * Toggle whether dates are in local time or UTC.
	 *
	 * @param {boolean} [flag] Set the flag instead of toggling it
	 * @fires mw.widgets.datetime.DateTimeFormatter.local
	 * @chainable
	 * @return {mw.widgets.datetime.DateTimeFormatter}
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.toggleLocal = function ( flag ) {
		if ( flag === undefined ) {
			flag = !this.local;
		} else {
			flag = !!flag;
		}
		if ( this.local !== flag ) {
			this.local = flag;
			this.emit( 'local', this.local );
		}
		return this;
	};

	/**
	 * Get the default date.
	 *
	 * @return {Date}
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.getDefaultDate = function () {
		return new Date( this.defaultDate.getTime() );
	};

	/**
	 * Fetch the field specification array for this object.
	 *
	 * See {@link #parseFieldSpec parseFieldSpec} for details on the return value structure.
	 *
	 * @return {Array}
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.getFieldSpec = function () {
		return this.parseFieldSpec( this.format );
	};

	/**
	 * Parse a format string into a field specification.
	 *
	 * The input is a string containing tags formatted as ${tag|param|param...}
	 * (for editable fields) and $!{tag|param|param...} (for non-editable fields).
	 * Most tags are defined by {@link #getFieldForTag getFieldForTag}, but a few
	 * are defined here:
	 * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary'
	 *   component is X.
	 * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary'
	 *   component is X.
	 *
	 * Elements of the returned array are strings or objects. Strings are meant to
	 * be displayed as-is. Objects are as returned by {@link #getFieldForTag getFieldForTag}.
	 *
	 * @protected
	 * @param {string} format
	 * @return {Array}
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.parseFieldSpec = function ( format ) {
		var m, last, tag, params, spec,
			ret = [],
			re = /(.*?)(\$(!?)\{([^}]+)\})/g;

		last = 0;
		while ( ( m = re.exec( format ) ) !== null ) {
			last = re.lastIndex;

			if ( m[ 1 ] !== '' ) {
				ret.push( m[ 1 ] );
			}

			params = m[ 4 ].split( '|' );
			tag = params.shift();
			spec = this.getFieldForTag( tag, params );
			if ( spec ) {
				if ( m[ 3 ] === '!' ) {
					spec.editable = false;
				}
				ret.push( spec );
			} else {
				ret.push( m[ 2 ] );
			}
		}
		if ( last < format.length ) {
			ret.push( format.slice( last ) );
		}

		return ret;
	};

	/**
	 * @typedef {Object} mw.widgets.datetime.DateTimeFormatter~FieldSpecificationObject
	 * @property {string|null} component Date component corresponding to this field, if any.
	 * @property {boolean} editable Whether this field is editable.
	 * @property {string} type What kind of field this is:
	 *  - 'static': The field is a static string; component will be null.
	 *  - 'number': The field is generally numeric.
	 *  - 'string': The field is generally textual.
	 *  - 'boolean': The field is a boolean.
	 *  - 'toggleLocal': The field represents {@link #getLocal this.getLocal()}.
	 *    Editing should directly call {@link #toggleLocal this.toggleLocal()}.
	 * @property {boolean} calendarComponent Whether this field is part of a calendar, e.g.
	 *  part of the date instead of the time.
	 * @property {number} size Maximum number of characters in the field (when
	 *  the 'intercalary' component is falsey). If 0, the field should be hidden entirely.
	 * @property {Object.<string,number>} intercalarySize Map from
	 *  'intercalary' component values to overridden sizes.
	 * @property {string} value For type='static', the string to display.
	 * @property {function(Mixed): string} formatValue A function to format a
	 *  component value as a display string.
	 * @property {function(string): Mixed} parseValue A function to parse a
	 *  display string into a component value. If parsing fails, returns undefined.
	 */

	/**
	 * Turn a tag into a field specification object.
	 *
	 * Fields implemented here are:
	 * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary'
	 *   component is X.
	 * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary'
	 *   component is X.
	 * - ${zone|#}: Timezone offset, "+0000" format.
	 * - ${zone|:}: Timezone offset, "+00:00" format.
	 * - ${zone|short}: Timezone from 'shortZones' configuration setting.
	 * - ${zone|full}: Timezone from 'fullZones' configuration setting.
	 *
	 * @protected
	 * @abstract
	 * @param {string} tag
	 * @param {string[]} params
	 * @return {FieldSpecificationObject} Field specification object, or null if the tag+params are unrecognized.
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) {
		var c, spec = null;

		switch ( tag ) {
			case 'intercalary':
			case 'not-intercalary':
				if ( params.length < 2 || !params[ 0 ] ) {
					return null;
				}
				spec = {
					component: null,
					calendarComponent: false,
					editable: false,
					type: 'static',
					value: params.slice( 1 ).join( '|' ),
					size: 0,
					intercalarySize: {}
				};
				if ( tag === 'intercalary' ) {
					spec.intercalarySize[ params[ 0 ] ] = spec.value.length;
				} else {
					spec.size = spec.value.length;
					spec.intercalarySize[ params[ 0 ] ] = 0;
				}
				return spec;

			case 'zone':
				switch ( params[ 0 ] ) {
					case '#':
					case ':':
						c = params[ 0 ] === '#' ? '' : ':';
						return {
							component: 'zone',
							calendarComponent: false,
							editable: true,
							type: 'toggleLocal',
							size: 5 + c.length,
							formatValue: function ( v ) {
								var o, r;
								if ( v ) {
									o = new Date().getTimezoneOffset();
									r = String( Math.abs( o ) % 60 );
									while ( r.length < 2 ) {
										r = '0' + r;
									}
									r = String( Math.floor( Math.abs( o ) / 60 ) ) + c + r;
									while ( r.length < 4 + c.length ) {
										r = '0' + r;
									}
									return ( o <= 0 ? '+' : '−' ) + r;
								} else {
									return '+00' + c + '00';
								}
							},
							parseValue: function ( v ) {
								var m;
								v = String( v ).trim();
								if ( ( m = /^([+-−])([0-9]{1,2}):?([0-9]{2})$/.test( v ) ) ) {
									return ( m[ 2 ] * 60 + m[ 3 ] ) * ( m[ 1 ] === '+' ? -1 : 1 );
								} else {
									return undefined;
								}
							}
						};

					case 'short':
					case 'full':
						spec = {
							component: 'zone',
							calendarComponent: false,
							editable: true,
							type: 'toggleLocal',
							values: params[ 0 ] === 'short' ? this.shortZones : this.fullZones,
							formatValue: this.formatSpecValue,
							parseValue: this.parseSpecValue
						};
						spec.size = Math.max.apply(
							// eslint-disable-next-line no-jquery/no-map-util
							null, $.map( spec.values, function ( v ) {
								return v.length;
							} )
						);
						return spec;
				}
				return null;

			default:
				return null;
		}
	};

	/**
	 * Format a value for a field specification.
	 *
	 * 'this' must be the field specification object. The intention is that you
	 * could just assign this function as the 'formatValue' for each field spec.
	 *
	 * Besides the publicly-documented fields, uses the following:
	 * - values: Enumerated values for the field
	 * - zeropad: Whether to pad the number with zeros.
	 *
	 * @protected
	 * @param {any} v
	 * @return {string}
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.formatSpecValue = function ( v ) {
		if ( v === undefined || v === null ) {
			return '';
		}

		if ( typeof v === 'boolean' || this.type === 'toggleLocal' ) {
			v = v ? 1 : 0;
		}

		if ( this.values ) {
			return this.values[ v ];
		}

		v = String( v );
		if ( this.zeropad ) {
			while ( v.length < this.size ) {
				v = '0' + v;
			}
		}
		return v;
	};

	/**
	 * Parse a value for a field specification.
	 *
	 * 'this' must be the field specification object. The intention is that you
	 * could just assign this function as the 'parseValue' for each field spec.
	 *
	 * Besides the publicly-documented fields, uses the following:
	 * - values: Enumerated values for the field
	 *
	 * @protected
	 * @param {string} v
	 * @return {number|string|null}
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.parseSpecValue = function ( v ) {
		var k, re;

		if ( v === '' ) {
			return null;
		}

		if ( !this.values ) {
			v = +v;
			if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
				return isNaN( v ) ? undefined : !!v;
			} else {
				return isNaN( v ) ? undefined : v;
			}
		}

		if ( v.normalize ) {
			v = v.normalize();
		}
		// eslint-disable-next-line security/detect-non-literal-regexp
		re = new RegExp( '^\\s*' + mw.util.escapeRegExp( v ), 'i' );
		for ( k in this.values ) {
			k = +k;
			if ( !isNaN( k ) && re.test( this.values[ k ] ) ) {
				if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
					return !!k;
				} else {
					return k;
				}
			}
		}
		return undefined;
	};

	/**
	 * Get components from a Date object.
	 *
	 * Most specific components are defined by the subclass. "Global" components
	 * are:
	 * - intercalary: {string} Non-falsey values are used to indicate intercalary days.
	 * - zone: {number} Timezone offset in minutes.
	 *
	 * @abstract
	 * @param {Date|null} date
	 * @return {Object} Components
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.getComponentsFromDate = function ( date ) {
		// Should be overridden by subclass
		return {
			zone: this.local ? date.getTimezoneOffset() : 0
		};
	};

	/**
	 * Get a Date object from components.
	 *
	 * @param {Object} components Date components
	 * @return {Date}
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.getDateFromComponents = function ( /* components */ ) {
		// Should be overridden by subclass
		return new Date();
	};

	/**
	 * Adjust a date.
	 *
	 * @param {Date|null} date To be adjusted
	 * @param {string} component To adjust
	 * @param {number} delta Adjustment amount
	 * @param {string} mode Adjustment mode:
	 *  - 'overflow': "Jan 32" => "Feb 1", "Jan 33" => "Feb 2", "Feb 0" => "Jan 31", etc.
	 *  - 'wrap': "Jan 32" => "Jan 1", "Jan 33" => "Jan 2", "Jan 0" => "Jan 31", etc.
	 *  - 'clip': "Jan 32" => "Jan 31", "Feb 32" => "Feb 28" (or 29), "Feb 0" => "Feb 1", etc.
	 * @return {Date} Adjusted date
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.adjustComponent = function ( date /* , component, delta, mode */ ) {
		// Should be overridden by subclass
		return date;
	};

	/**
	 * Get the column headings (weekday abbreviations) for a calendar grid.
	 *
	 * Null-valued columns are hidden if getCalendarData() returns no "day" object
	 * for all days in that column.
	 *
	 * @abstract
	 * @return {Array} string or null
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarHeadings = function () {
		// Should be overridden by subclass
		return [];
	};

	/**
	 * Test whether two dates are in the same calendar grid.
	 *
	 * @abstract
	 * @param {Date} date1
	 * @param {Date} date2
	 * @return {boolean}
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) {
		// Should be overridden by subclass
		return date1.getTime() === date2.getTime();
	};

	/**
	 * Test whether the date parts of two Dates are equal.
	 *
	 * @param {Date} date1
	 * @param {Date} date2
	 * @return {boolean}
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.datePartIsEqual = function ( date1, date2 ) {
		if ( this.local ) {
			return (
				date1.getFullYear() === date2.getFullYear() &&
				date1.getMonth() === date2.getMonth() &&
				date1.getDate() === date2.getDate()
			);
		} else {
			return (
				date1.getUTCFullYear() === date2.getUTCFullYear() &&
				date1.getUTCMonth() === date2.getUTCMonth() &&
				date1.getUTCDate() === date2.getUTCDate()
			);
		}
	};

	/**
	 * Test whether the time parts of two Dates are equal.
	 *
	 * @param {Date} date1
	 * @param {Date} date2
	 * @return {boolean}
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.timePartIsEqual = function ( date1, date2 ) {
		if ( this.local ) {
			return (
				date1.getHours() === date2.getHours() &&
				date1.getMinutes() === date2.getMinutes() &&
				date1.getSeconds() === date2.getSeconds() &&
				date1.getMilliseconds() === date2.getMilliseconds()
			);
		} else {
			return (
				date1.getUTCHours() === date2.getUTCHours() &&
				date1.getUTCMinutes() === date2.getUTCMinutes() &&
				date1.getUTCSeconds() === date2.getUTCSeconds() &&
				date1.getUTCMilliseconds() === date2.getUTCMilliseconds()
			);
		}
	};

	/**
	 * Test whether toggleLocal() changes the date part.
	 *
	 * @param {Date} date
	 * @return {boolean}
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.localChangesDatePart = function ( date ) {
		return (
			date.getUTCFullYear() !== date.getFullYear() ||
			date.getUTCMonth() !== date.getMonth() ||
			date.getUTCDate() !== date.getDate()
		);
	};

	/**
	 * Create a new Date by merging the date part from one with the time part from
	 * another.
	 *
	 * @param {Date} datepart
	 * @param {Date} timepart
	 * @return {Date}
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.mergeDateAndTime = function ( datepart, timepart ) {
		var ret = new Date( datepart.getTime() );

		if ( this.local ) {
			ret.setHours(
				timepart.getHours(),
				timepart.getMinutes(),
				timepart.getSeconds(),
				timepart.getMilliseconds()
			);
		} else {
			ret.setUTCHours(
				timepart.getUTCHours(),
				timepart.getUTCMinutes(),
				timepart.getUTCSeconds(),
				timepart.getUTCMilliseconds()
			);
		}

		return ret;
	};

	/**
	 * @typedef {Object} mw.widgets.datetime.DateTimeFormatter~CalendarGridData
	 * @property {string} header String to display as the calendar header
	 * @property {string} monthComponent Component to adjust by ±1 to change months.
	 * @property {string} dayComponent Component to adjust by ±1 to change days.
	 * @property {string} [weekComponent] Component to adjust by ±1 to change
	 *   weeks. If omitted, the dayComponent should be adjusted by ±the number of
	 *   non-nullable columns returned by this.getCalendarHeadings() to change weeks.
	 * @property {Array} rows Array of arrays of "day" objects or null/undefined.
	 */

	/**
	 * Get data for a calendar grid.
	 *
	 * A "day" object is:
	 * - display: {string} Display text for the day.
	 * - date: {Date} Date to use when the day is selected.
	 * - extra: {string|null} 'prev' or 'next' on days used to fill out the weeks
	 *   at the start and end of the month.
	 *
	 * In any one result object, 'extra' + 'display' will always be unique.
	 *
	 * @abstract
	 * @param {Date|null} current Current date
	 * @return {CalendarGridData} Data
	 */
	mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarData = function ( /* components */ ) {
		// Should be overridden by subclass
		return {
			header: '',
			monthComponent: 'month',
			dayComponent: 'day',
			rows: []
		};
	};

}() );