'use strict';

const config = require( './config.json' );

const instanceCache = {
	user: null,
	utc: null,
	site: null
};

let supportsOffsetCache = null;

// Private function collection.
// JSDOC doesn't like top-level function declarations.
// These can probably be private static methods when we have ES2022.
const priv = {
	/**
	 * Determine whether the browser supports offset strings like "+00:00", which
	 * were introduced in ECMA-402, 11th edition (2024).
	 *
	 * @return {boolean}
	 */
	supportsOffset() {
		if ( supportsOffsetCache === null ) {
			try {
				// eslint-disable-next-line no-new
				new Intl.DateTimeFormat( undefined, { timeZone: '+01:00' } );
				supportsOffsetCache = true;
			} catch ( e ) {
				supportsOffsetCache = false;
			}
		}
		return supportsOffsetCache;
	},

	/**
	 * Convert an offset in minutes to a string suitable for the timeZone option to
	 * the Intl.DateTimeFormat constructor. We use the MW sign convention for the
	 * input, where positive is positive, not the inverted JS convention.
	 *
	 * @param {number} offset
	 * @return {string|undefined}
	 */
	offsetToZone( offset ) {
		const hour = Math.floor( Math.abs( offset ) / 60 );
		const minute = Math.abs( offset ) % 60;
		if ( priv.supportsOffset() ) {
			const pad = ( x ) => `${ x < 10 ? '0' : '' }${ x }`;
			return `${ offset < 0 ? '-' : '+' }${ pad( hour ) }:${ pad( minute ) }`;
		}
		if ( minute !== 0 ) {
			// Can't represent this zone -- just use the system time zone
			return undefined;
		}
		return `Etc/GMT${ offset < 0 ? '+' : '-' }${ hour }`;
	},

	/**
	 * Check if a zone name is usable as an input to the Intl.DateTimeFormat constructor.
	 * If this returns false, we will use the offset instead.
	 *
	 * @param {string} zoneName
	 * @return {boolean}
	 */
	isKnownZoneName( zoneName ) {
		// Feature test for Safari 11.1
		// eslint-disable-next-line compat/compat
		return Intl.supportedValuesOf && Intl.supportedValuesOf( 'timeZone' ).includes( zoneName );
	},

	/**
	 * Convert a MW time zone option value to an identifier suitable for
	 * Intl.DateTimeFormat. UserOptionsModule does some normalisation so this is
	 * not quite as complex as UserTimeCorrection::parse()
	 *
	 * @param {string|null} optionValue The user option value, if any
	 * @param {string} localZone The configured default zone
	 * @param {number} localOffset The configured default offset, in minutes
	 * @return {string|undefined}
	 */
	normalizeZone( optionValue, localZone, localOffset ) {
		if ( optionValue ) {
			const parts = optionValue.split( '|', 3 );
			if ( parts[ 0 ] === 'ZoneInfo' ) {
				if ( priv.isKnownZoneName( parts[ 2 ] ) ) {
					return parts[ 2 ];
				} else {
					return priv.offsetToZone( parseInt( parts[ 1 ], 10 ) );
				}
			} else if ( parts[ 0 ] === 'Offset' ) {
				return priv.offsetToZone( parts[ 1 ] );
			}
		}
		return priv.isKnownZoneName( localZone ) ? localZone : priv.offsetToZone( localOffset );
	},

	/**
	 * Get the normalized user time zone
	 *
	 * @return {string|undefined}
	 */
	getUserZone() {
		return priv.normalizeZone(
			mw.user.options.get( 'timecorrection' ),
			config.localZone,
			config.localOffset
		);
	},

	/**
	 * Get the normalized site default zone
	 *
	 * @return {string|undefined}
	 */
	getSiteZone() {
		return priv.normalizeZone( null, config.localZone, config.localOffset );
	},

	/**
	 * Get the date style desired by the user, e.g. "dmy".
	 *
	 * @return {string}
	 */
	getUserDateStyle() {
		const def = config.defaultStyle;
		const style = mw.user.options.get( 'date', def );
		return ( !style || style === 'default' ) ? def : style;
	},

	/**
	 * Convert an array of parts as returned by Intl.DateTimeFormat.formatParts
	 * to an object.
	 *
	 * @param {Array} parts
	 * @return {Object}
	 */
	partsToInfo( parts ) {
		const info = {};
		for ( const part of parts ) {
			info[ part.type ] = part.value;
		}
		return info;
	},

	/**
	 * Substitute parameters into the given pattern string
	 *
	 * @param {Object} info
	 * @param {string} pattern
	 * @return {string}
	 */
	formatPattern( info, pattern ) {
		return pattern.replace(
			/\{(\w+)}/g,
			( _, name ) => name in info ? info[ name ] : '?'
		);
	},

	/**
	 * Convert an English "long offset" part value to a string like "+01:00"
	 *
	 * @param {string} offset
	 * @return {string}
	 */
	longOffsetToIsoOffset( offset ) {
		const m = /(GMT|UTC)?([+-][0-9]{2}:[0-9]{2})/.exec( offset );
		return m ? m[ 2 ] : '+00:00';
	}
};

/**
 * @description
 * Time and date formatter class.
 *
 * The main aim of this class is to make it easy to produce a date which is
 * consistent with the way server-side MediaWiki formats dates, in the current
 * user's timezone and preferred format.
 *
 * The input format is always a native Date object. This isn't a date parser
 * class.
 *
 * The most common operations are available as static methods, which have no
 * dependency on "this", so they can be called via destructuring assignment.
 *
 * The constructor is internal. Instances should be obtained via the static
 * factory methods.
 *
 * @example
 * // Static interface
 * const { formatTimeAndDate } = require( 'mediawiki.DateFormatter' );
 * const now = formatTimeAndDate( new Date() );
 *
 * // Non-static interface
 * const DateFormatter = require( 'mediawiki.DateFormatter' );
 * const mwTimestamp = DateFormatter.forUtc().formatMw( new Date() );
 *
 * @since 1.44
 * @exports mediawiki.DateFormatter
 */
class DateFormatter {

	/**
	 * Get a DateFormatter instance configured for the current user
	 *
	 * @return {module:mediawiki.DateFormatter}
	 */
	static forUser() {
		if ( !instanceCache.user ) {
			instanceCache.user = new DateFormatter(
				config.locales,
				config.formats,
				priv.getUserZone(),
				priv.getUserDateStyle()
			);
		}
		return instanceCache.user;
	}

	/**
	 * Get a DateFormatter instance, configured for the current user except with
	 * the time zone set to UTC.
	 *
	 * @return {module:mediawiki.DateFormatter}
	 */
	static forUtc() {
		if ( !instanceCache.utc ) {
			instanceCache.utc = new DateFormatter(
				config.locales,
				config.formats,
				'Etc/GMT',
				priv.getUserDateStyle()
			);
		}
		return instanceCache.utc;
	}

	/**
	 * Get a DateFormatter instance, configured for the current user except with
	 * the site default time zone.
	 *
	 * @return {module:mediawiki.DateFormatter}
	 */
	static forSiteZone() {
		if ( !instanceCache.site ) {
			instanceCache.site = new DateFormatter(
				config.locales,
				config.formats,
				priv.getSiteZone(),
				priv.getUserDateStyle()
			);
		}
		return instanceCache.site;
	}

	/**
	 * Format a Date to a time and date string (static variant)
	 *
	 * @param {Date} date
	 * @return {string}
	 */
	static formatTimeAndDate( date ) {
		return DateFormatter.forUser().formatTimeAndDate( date );
	}

	/**
	 * Format a Date to a date string (static variant)
	 *
	 * @param {Date} date
	 * @return {string}
	 */
	static formatDate( date ) {
		return DateFormatter.forUser().formatDate( date );
	}

	/**
	 * Format a Date to a time string (static variant)
	 *
	 * @param {Date} date
	 * @return {string}
	 */
	static formatTime( date ) {
		return DateFormatter.forUser().formatTime( date );
	}

	/**
	 * Format a Date to a date string without the year (static variant)
	 *
	 * @param {Date} date
	 * @return {string}
	 */
	static formatPrettyDate( date ) {
		return DateFormatter.forUser().formatPrettyDate( date );
	}

	// No static method for formatMw() -- use forUtc().formatMw()

	/**
	 * Format a Date in ISO 8601 format, including timezone designator.
	 * (static variant)
	 *
	 * @param {Date} date
	 * @return {string}
	 */
	static formatIso( date ) {
		return DateFormatter.forUser().formatIso( date );
	}

	/**
	 * Format a Date in ISO 8601 format, without the timezone designator,
	 * implying unqualified local time, and without seconds. This is suitable
	 * for passing to a datetime-local input. (static variant)
	 *
	 * @param {Date} date
	 * @return {string}
	 */
	static formatForDateTimeInput( date ) {
		return DateFormatter.forUser().formatForDateTimeInput( date );
	}

	/**
	 * Format a date range, showing time and date. (static variant)
	 *
	 * TODO: Have MW provide localisation.
	 *   This function depends on the browser for localisation. It does not
	 *   properly respect the user's date format preferences, and has limited
	 *   language support.
	 *
	 * @param {Date} date1
	 * @param {Date} date2
	 * @return {string}
	 */
	static formatTimeAndDateRange( date1, date2 ) {
		return DateFormatter.forUser().formatTimeAndDateRange( date1, date2 );
	}

	/**
	 * Format a date range, showing time only. (static variant)
	 *
	 * TODO: Have MW provide localisation.
	 *
	 * @param {Date} date1
	 * @param {Date} date2
	 * @return {string}
	 */
	static formatTimeRange( date1, date2 ) {
		return DateFormatter.forUser().formatTimeRange( date1, date2 );
	}

	/**
	 * Format a date range, showing date only. (static variant)
	 *
	 * TODO: Have MW provide localisation.
	 *
	 * @param {Date} date1
	 * @param {Date} date2
	 * @return {string}
	 */
	static formatDateRange( date1, date2 ) {
		return DateFormatter.forUser().formatDateRange( date1, date2 );
	}

	/**
	 * Get the short name of the time zone, e.g. "PST".
	 *
	 * @param {Date} date The reference date
	 * @return {string}
	 */
	static getShortZoneName( date ) {
		return DateFormatter.forUser().getShortZoneName( date );
	}

	/**
	 * Get the time zone offset as a number of minutes, in the sign-preserving
	 * convention, e.g. "+01:00" becomes 60.
	 *
	 * @param {Date} date The reference date for DST
	 * @return {number}
	 */
	static getZoneOffsetMinutes( date ) {
		return DateFormatter.forUser().getZoneOffsetMinutes( date );
	}

	/**
	 * Clear the instance cache. For testing.
	 *
	 * @internal
	 * @ignore
	 */
	static clearInstanceCache() {
		instanceCache.user = null;
		instanceCache.utc = null;
		instanceCache.site = null;
		supportsOffsetCache = null;
	}

	/**
	 * Format a Date as relative time such as "40 seconds ago", "3 months ago", or "in 40 seconds". (static variant)
	 *
	 * If the browser does not support Intl.RelativeTimeFormat (Safari 11.1, iOS Safari 11.3-11.4),
	 * it uses `DateFormatter.formatTimeAndDate()` as fallback.
	 *
	 * @param {Date} date
	 * @param {Date} [now]
	 * @return {string}
	 */
	static formatRelativeTimeOrDate( date, now = new Date() ) {
		if ( typeof Intl === 'undefined' || typeof Intl.RelativeTimeFormat !== 'function' ) {
			// Intl.RelativeTimeFormat() is not supported in Safari 11.1, iOS Safari 11.3-11.4
			return DateFormatter.formatTimeAndDate( date );
		}
		// from mediawiki/extensions/MobileFrontend/src/mobile.startup/time.js
		const seconds = ( date.getTime() - now.getTime() ) / 1000;
		const units = [ 'seconds', 'minutes', 'hours', 'days', 'months', 'years' ];
		const limits = [ 1, 60, 3600, 86400, 2592000, 31536000 ];
		let i = 0;
		while ( i < limits.length && Math.abs( seconds ) > limits[ i + 1 ] ) {
			++i;
		}
		const value = Math.round( seconds / limits[ i ] );
		// eslint-disable-next-line compat/compat
		return new Intl.RelativeTimeFormat( config.locales ).format( value, units[ i ] );
	}

	/**
	 * @internal
	 * @hideconstructor
	 *
	 * @param {string[]} locales The locale fallback chain
	 * @param {Object} formats The available date formats, indexed by combined
	 *   style+type key, e.g. "dmy pretty". The property values are objects with
	 *   the following properties:
	 *     - locale: {string} A locale name, overriding the locales parameter
	 *     - pattern: {string} A pattern for formatPattern()
	 *     - options: {object} Options to pass to Intl.DateTimeFormat()
	 *     - error: {string} A parse error relating to the localisation source
	 * @param {string|undefined} zone The normalized time zone identifier
	 * @param {string} style The selected date style, one of those available in
	 *   formats, e.g. "dmy". The user's preferred format with fallbacks applied.
	 */
	constructor( locales, formats, zone, style ) {
		this.locales = locales;
		this.formats = Object.assign( {}, formats );
		const machineTemplate = {
			locale: 'en',
			options: {
				year: 'numeric', month: '2-digit', day: '2-digit',
				hour: '2-digit', minute: '2-digit', second: '2-digit',
				hour12: false, timeZoneName: 'longOffset'
			}
		};
		this.formats.datetime = Object.assign(
			{ pattern: '{year}-{month}-{day}T{hour}:{minute}' },
			machineTemplate
		);
		this.formats.iso = Object.assign(
			{ pattern: '{year}-{month}-{day}T{hour}:{minute}:{second}{mwZoneOffset}' },
			machineTemplate
		);
		this.formats.mw = Object.assign(
			{ pattern: '{year}{month}{day}{hour}{minute}{second}' },
			machineTemplate
		);
		this.formats.monthNumber = { locale: 'en', options: { month: 'numeric' } };
		this.zone = zone;
		this.intlFormatters = {};
		this.style = style;
	}

	/**
	 * Format a Date to a time and date string (non-static)
	 *
	 * @param {Date} date
	 * @return {string}
	 */
	formatTimeAndDate( date ) {
		return this.formatInternal( this.style, 'both', date );
	}

	/**
	 * Format a Date to a date string (non-static)
	 *
	 * @param {Date} date
	 * @return {string}
	 */
	formatDate( date ) {
		return this.formatInternal( this.style, 'date', date );
	}

	/**
	 * Format a Date to a time string (non-static)
	 *
	 * @param {Date} date
	 * @return {string}
	 */
	formatTime( date ) {
		return this.formatInternal( this.style, 'time', date );
	}

	/**
	 * Format a Date to a date string without the year (non-static)
	 *
	 * @param {Date} date
	 * @return {string}
	 */
	formatPrettyDate( date ) {
		return this.formatInternal( this.style, 'pretty', date );
	}

	/**
	 * Format a Date to a MediaWiki 14-character timestamp. Since MediaWiki
	 * timestamps are conventionally UTC, this should typically be called on an
	 * instance retrieved with forUtc().
	 *
	 * @param {Date} date
	 * @return {string}
	 */
	formatMw( date ) {
		return this.formatInternal( null, 'mw', date );
	}

	/**
	 * Format a Date in ISO 8601 format, including timezone designator.
	 * (non-static)
	 *
	 * @param {Date} date
	 * @return {string}
	 */
	formatIso( date ) {
		return this.formatInternal( null, 'iso', date );
	}

	/**
	 * Format a Date in ISO 8601 format, without the timezone designator,
	 * implying unqualified local time, and without seconds. This is suitable
	 * for passing to a datetime-local input. (non-static)
	 *
	 * @param {Date} date
	 * @return {string}
	 */
	formatForDateTimeInput( date ) {
		return this.formatInternal( null, 'datetime', date );
	}

	/**
	 * Format a date range, showing time and date. (non-static)
	 *
	 * TODO: Have MW provide localisation.
	 *   This function depends on the browser for localisation. It does not
	 *   properly respect the user's date format preferences, and has limited
	 *   language support.
	 *
	 * @param {Date} date1
	 * @param {Date} date2
	 * @return {string}
	 */
	formatTimeAndDateRange( date1, date2 ) {
		return this.formatRangeInternal( this.style, 'both', date1, date2 );
	}

	/**
	 * Format a date range, showing time only. (non-static)
	 *
	 * TODO: Have MW provide localisation.
	 *
	 * @param {Date} date1
	 * @param {Date} date2
	 * @return {string}
	 */
	formatTimeRange( date1, date2 ) {
		return this.formatRangeInternal( this.style, 'time', date1, date2 );
	}

	/**
	 * Format a date range, showing date only. (non-static)
	 *
	 * TODO: Have MW provide localisation.
	 *
	 * @param {Date} date1
	 * @param {Date} date2
	 * @return {string}
	 */
	formatDateRange( date1, date2 ) {
		return this.formatRangeInternal( this.style, 'date', date1, date2 );
	}

	/**
	 * Get the short name of the time zone, e.g. "PST".
	 *
	 * @param {Date} date The reference date
	 * @return {string}
	 */
	getShortZoneName( date ) {
		return this.getLocalInfoInternal( 'timeZoneName', 'short', date );
	}

	/**
	 * Get the time zone offset as a number of minutes, in the sign-preserving
	 * convention, e.g. "+01:00" becomes 60.
	 *
	 * @param {Date} date The reference date for DST
	 * @return {number}
	 */
	getZoneOffsetMinutes( date ) {
		const [ h, m ] =
			priv.longOffsetToIsoOffset(
				this.getLocalInfoInternal( 'timeZoneName', 'longOffset', date )
			).split( ':' );
		return h * 60 + Math.sign( +h ) * m;
	}

	/**
	 * Format a date/time with a specified style.
	 *
	 * @internal
	 * @ignore
	 *
	 * @param {string|null} style The style, e.g. "dmy", or null to use an
	 *   internal type identifier for a machine-readable style.
	 * @param {string} type The type, e.g. "both" for both time and date. If
	 *   style is null then this can be an internal style added by the
	 *   constructor.
	 * @param {Date} date
	 * @return {string}
	 */
	formatInternal( style, type, date ) {
		const formatName = this.makeValidFormatName( style, type );
		const formatter = this.getIntlFormatInternal( formatName );
		const pattern = this.formats[ formatName ].pattern;
		if ( pattern ) {
			const info = priv.partsToInfo( formatter.formatToParts( date ) );
			if ( pattern.includes( '{mwMonth' ) ) {
				const i = +this.getIntlFormatInternal( 'monthNumber' ).format( date );
				[ info.mwMonth, info.mwMonthGen, info.mwMonthAbbrev ] = config.months[ i ] || [];
			}
			if ( pattern.includes( '{mwZoneOffset}' ) ) {
				info.mwZoneOffset = priv.longOffsetToIsoOffset( info.timeZoneName );
			}
			return priv.formatPattern( info, pattern );
		} else {
			return formatter.format( date );
		}
	}

	/**
	 * Validate a style/type and combine them into a single string, falling
	 * back to the default style if the user style is not available with the
	 * specified type.
	 *
	 * @internal
	 * @ignore
	 *
	 * @param {string|null} style
	 * @param {string} type
	 * @return {string}
	 */
	makeValidFormatName( style, type ) {
		if ( !style ) {
			return type;
		}
		// Try the specified style, then the localisation's default style
		for ( const tryStyle of [ style, config.defaultStyle ] ) {
			const name = `${ tryStyle } ${ type }`;
			if ( name in this.formats ) {
				return name;
			}
		}
		// Perhaps an invalid type, or bad config?
		throw new Error( `Unable to find a valid date format for "${ style } ${ type }"` );
	}

	/**
	 * Format a time/date range with a specified style
	 *
	 * @internal
	 * @ignore
	 *
	 * @param {string} style
	 * @param {string} type
	 * @param {Date} date1
	 * @param {Date} date2
	 * @return {string}
	 */
	formatRangeInternal( style, type, date1, date2 ) {
		const formatName = this.makeValidFormatName( style, type );
		const formatter = this.getIntlFormatInternal( formatName );
		const pattern = this.formats[ formatName ].rangePattern;
		if ( pattern ) {
			return priv.formatPattern(
				priv.partsToInfo( formatter.formatRangeToParts( date1, date2 ) ),
				pattern
			);
		} else {
			return formatter.formatRange( date1, date2 );
		}
	}

	/**
	 * Get a DateTimeFormat object configured for the specified combined
	 * type/style.
	 *
	 * @internal
	 * @ignore
	 *
	 * @param {string} formatName
	 * @return {Intl.DateTimeFormat}
	 */
	getIntlFormatInternal( formatName ) {
		if ( !( formatName in this.intlFormatters ) ) {
			if ( !( formatName in this.formats ) ) {
				throw new Error( `Unknown date format "${ formatName }"` );
			}
			const format = this.formats[ formatName ];
			const locale = format.locale || this.locales;
			const options = Object.assign( {}, format.options );
			options.timeZone = this.zone;
			this.intlFormatters[ formatName ] = new Intl.DateTimeFormat( locale, options );
		}
		return this.intlFormatters[ formatName ];
	}

	/**
	 * Extract a part value from a formatted date, with the given configuration
	 * for that part value.
	 *
	 * @internal
	 * @ignore
	 *
	 * @param {string} fieldName
	 * @param {string} configValue
	 * @param {Date} date
	 * @return {string}
	 */
	getLocalInfoInternal( fieldName, configValue, date ) {
		const key = `info ${ fieldName }.${ configValue }`;
		if ( !this.formats[ key ] ) {
			this.formats[ key ] = { options: { [ fieldName ]: configValue } };
		}
		const formatter = this.getIntlFormatInternal( key );
		const info = priv.partsToInfo( formatter.formatToParts( date ) );
		return info[ fieldName ] || '';
	}
}

if ( window.QUnit ) {
	DateFormatter.priv = priv;
	DateFormatter.config = config;
}

module.exports = DateFormatter;
