/*! DateTime for DataTables.net
 * Copyright (c) SpryMedia Ltd - datatables.net/license
 */

import DataTable, { Context, Dom, util } from 'datatables.net';
import './interface';
import { Defaults, DomInternal, Settings } from './interface';

// Supported formatting and parsing libraries:
// * Moment
// * Luxon
// * DayJS
var dateLib;

export class DateTime {
	/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
	 * Statics
	 */

	/**
	 * Use a specific compatible date library
	 */
	public static use(lib) {
		dateLib = lib;
	}

	/**
	 * For generating unique namespaces
	 */
	private static _instance = 0;

	/**
	 * To indicate to DataTables what type of library this is
	 */
	public static type = 'DateTime';

	/**
	 * Defaults for the date time picker
	 */
	public static defaults: Defaults = {
		alwaysVisible: false,
		attachTo: 'body',
		buttons: {
			clear: false,
			selected: false,
			today: false
		},
		classPrefix: 'dt-datetime',
		disableDays: null,
		display: null,
		firstDay: null,
		format: 'YYYY-MM-DD',
		hoursAvailable: null,
		i18n: {
			clear: 'Clear',
			previous: 'Previous',
			next: 'Next',
			months: [
				'January',
				'February',
				'March',
				'April',
				'May',
				'June',
				'July',
				'August',
				'September',
				'October',
				'November',
				'December'
			],
			weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
			amPm: ['am', 'pm'],
			hours: 'Hour',
			minutes: 'Minute',
			seconds: 'Second',
			unknown: '-',
			today: 'Today',
			selected: 'Selected'
		},
		maxDate: null,
		minDate: null,
		minutesAvailable: null,
		minutesIncrement: 1, // deprecated
		strict: true,
		locale: 'en',
		onChange: () => {},
		secondsAvailable: null,
		secondsIncrement: 1, // deprecated
		showWeekNumber: false,
		yearRange: 25
	};

	public static version = '2.0.0';

	/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
	 * Public methods
	 */

	/**
	 * Destroy the control
	 */
	public destroy() {
		var namespace = this.s.namespace;

		clearTimeout(this.s.showTo);
		this._hide(true);
		this.dom.container.off().empty();
		this.dom.input
			.classRemove('dt-datetime')
			.attrRemove('autocomplete')
			.off('.datetime');
		Dom.s(document).off('.' + namespace);
		Dom.w.off('.' + namespace);
	}

	public display(year, month) {
		if (year !== undefined) {
			this.s.display.setUTCFullYear(year);
		}

		if (month !== undefined) {
			this.s.display.setUTCMonth(month - 1);
		}

		if (year !== undefined || month !== undefined) {
			this._setTitle();
			this._setCalander();

			return this;
		}

		return this.s.display
			? {
					month: this.s.display.getUTCMonth() + 1,
					year: this.s.display.getUTCFullYear()
			  }
			: {
					month: null,
					year: null
			  };
	}

	public errorMsg(msg) {
		var error = this.dom.error;

		if (msg) {
			error.html(msg);
		}
		else {
			error.empty();
		}

		return this;
	}

	public hide() {
		this._hide();

		return this;
	}

	public max(date) {
		this.c.maxDate = typeof date === 'string' ? new Date(date) : date;

		this._optionsTitle();
		this._setCalander();

		return this;
	}

	public min(date) {
		this.c.minDate = typeof date === 'string' ? new Date(date) : date;

		this._optionsTitle();
		this._setCalander();

		return this;
	}

	/**
	 * Check if an element belongs to this control
	 *
	 * @param  {node} node Element to check
	 * @return {boolean}   true if owned by this control, false otherwise
	 */
	public owns(node) {
		return Dom.s(node).closest(this.dom.container.get(0)).count() > 0;
	}

	/**
	 * Get the value
	 */
	public val(): Date;
	/**
	 * Set the value
	 *
	 * @param set Value to set
	 * @param write Flag to indicate if the formatted value
	 *   should be written into the input element
	 */
	public val(set: string | Date, write?: boolean);
	public val(set?: string | Date, write: boolean = true) {
		if (set === undefined) {
			return this.s.d;
		}

		var oldVal = this.s.d;

		if (set instanceof Date) {
			this.s.d = this._dateToUtc(set);
		}
		else if (set === null || set === '') {
			this.s.d = null;
		}
		else if (set === '--now') {
			this.s.d = this._dateToUtc(new Date());
		}
		else if (typeof set === 'string') {
			this.s.d = this._dateToUtc(this._convert(set, this.c.format, null));
		}

		if (write || write === undefined) {
			if (this.s.d) {
				this._writeOutput(
					false,
					(oldVal === null && this.s.d !== null) ||
						(oldVal !== null && this.s.d === null) ||
						oldVal.toString() !== this.s.d.toString()
				);
			}
			else {
				// The input value was not valid...
				this.dom.input.val(set as string);
			}
		}

		// Need something to display
		if (this.s.d) {
			this.s.display = new Date(this.s.d.toString());
		}
		else if (this.c.display) {
			this.s.display = new Date();
			this.s.display.setUTCDate(1);
			this.display(this.c.display.year, this.c.display.month);
		}
		else {
			this.s.display = new Date();
		}

		// Set the day of the month to be 1 so changing between months doesn't
		// run into issues when going from day 31 to 28 (for example)
		this.s.display.setUTCDate(1);

		// Update the display elements for the new value
		this._setTitle();
		this._setCalander();
		this._setTime();

		return this;
	}

	/**
	 * Similar to `val()` but uses a given date / time format
	 *
	 * @param format Format to get the data as (getter) or that is input
	 *   (setter)
	 * @param val Value to write (if undefined, used as a getter)
	 * @returns
	 */
	public valFormat(format, val) {
		if (!val) {
			return this._convert(this.val(), null, format);
		}

		// Convert from the format given here to the instance's configured
		// format
		this.val(this._convert(val, format, null));

		return this;
	}

	/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
	 * Private properties
	 */

	private dom: DomInternal;
	private c: Defaults;
	private s: Settings;

	/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
	 * Constructor
	 */

	constructor(input, opts) {
		// Attempt to auto detect the formatting library (if there is one).
		// Having it in the constructor allows load order independence.
		let win = window as any;

		if (typeof dateLib === 'undefined') {
			dateLib = win.moment
				? win.moment
				: win.dayjs
				? win.dayjs
				: win.luxon
				? win.luxon
				: null;
		}

		this.c = util.object.assignDeep({}, DateTime.defaults, opts);
		var classPrefix = this.c.classPrefix;

		// Only IS8601 dates are supported without moment, dayjs or luxon
		if (!dateLib && this.c.format !== 'YYYY-MM-DD') {
			throw "DateTime: Without momentjs, dayjs or luxon only the format 'YYYY-MM-DD' can be used";
		}

		if (this._isLuxon() && this.c.format == 'YYYY-MM-DD') {
			this.c.format = 'yyyy-MM-dd';
		}

		// Min and max need to be `Date` objects in the config
		if (typeof this.c.minDate === 'string') {
			this.c.minDate = new Date(this.c.minDate);
		}
		if (typeof this.c.maxDate === 'string') {
			this.c.maxDate = new Date(this.c.maxDate);
		}

		// DOM structure
		var structure = Dom.c('div')
			.classAdd(classPrefix)
			.html(
				'<div class="' +
					classPrefix +
					'-date">' +
					'<div class="' +
					classPrefix +
					'-title">' +
					'<div class="' +
					classPrefix +
					'-iconLeft">' +
					'<button type="button"></button>' +
					'</div>' +
					'<div class="' +
					classPrefix +
					'-iconRight">' +
					'<button type="button"></button>' +
					'</div>' +
					'<div class="' +
					classPrefix +
					'-label">' +
					'<span></span>' +
					'<select class="' +
					classPrefix +
					'-month"></select>' +
					'</div>' +
					'<div class="' +
					classPrefix +
					'-label">' +
					'<span></span>' +
					'<select class="' +
					classPrefix +
					'-year"></select>' +
					'</div>' +
					'</div>' +
					'<div class="' +
					classPrefix +
					'-buttons">' +
					'<a class="' +
					classPrefix +
					'-clear"></a>' +
					'<a class="' +
					classPrefix +
					'-today"></a>' +
					'<a class="' +
					classPrefix +
					'-selected"></a>' +
					'</div>' +
					'<div class="' +
					classPrefix +
					'-calendar"></div>' +
					'</div>' +
					'<div class="' +
					classPrefix +
					'-time">' +
					'<div class="' +
					classPrefix +
					'-hours"></div>' +
					'<div class="' +
					classPrefix +
					'-minutes"></div>' +
					'<div class="' +
					classPrefix +
					'-seconds"></div>' +
					'</div>' +
					'<div class="' +
					classPrefix +
					'-error"></div>'
			);

		this.dom = {
			container: structure,
			date: structure.find('.' + classPrefix + '-date'),
			title: structure.find('.' + classPrefix + '-title'),
			calendar: structure.find('.' + classPrefix + '-calendar'),
			time: structure.find('.' + classPrefix + '-time'),
			error: structure.find('.' + classPrefix + '-error'),
			buttons: structure.find('.' + classPrefix + '-buttons'),
			clear: structure.find('.' + classPrefix + '-clear'),
			today: structure.find('.' + classPrefix + '-today'),
			selected: structure.find('.' + classPrefix + '-selected'),
			previous: structure.find('.' + classPrefix + '-iconLeft'),
			next: structure.find('.' + classPrefix + '-iconRight'),
			input: Dom.s(input)
		};

		this.s = {
			d: null,
			display: null,
			minutesRange: null,
			secondsRange: null,
			namespace: 'datetime-' + DateTime._instance++,
			parts: {
				date: this.c.format.match(/[yYMDd]|L(?!T)|l/) !== null,
				time: this.c.format.match(/[Hhm]|LT|LTS/) !== null,
				seconds: this.c.format.indexOf('s') !== -1,
				hours12: this.c.format.match(/[haA]/) !== null
			},
			showTo: null
		};

		this.dom.container
			.append(this.dom.date)
			.append(this.dom.time)
			.append(this.dom.error);

		this.dom.date
			.append(this.dom.title)
			.append(this.dom.buttons)
			.append(this.dom.calendar);

		this.dom.input.classAdd('dt-datetime');

		this._init();
	}

	/**
	 * Build the control and assign initial event handlers
	 */
	private _init() {
		var that = this;
		var classPrefix = this.c.classPrefix;
		var last = this.dom.input.val();
		var namespace = this.s.namespace;

		var onChange = function () {
			var curr = that.dom.input.val();

			if (curr !== last) {
				that.c.onChange.call(
					that,
					curr,
					that.s.d,
					that.dom.input.get(0)
				);
				last = curr;
			}
		};

		if (!this.s.parts.date) {
			this.dom.date.css('display', 'none');
		}

		if (!this.s.parts.time) {
			this.dom.time.css('display', 'none');
		}

		if (!this.s.parts.seconds) {
			this.dom.time.children('div.' + classPrefix + '-seconds').remove();
			this.dom.time.children('span').eq(1).remove();
		}

		if (!this.c.buttons.clear) {
			this.dom.clear.css('display', 'none');
		}

		if (!this.c.buttons.today) {
			this.dom.today.css('display', 'none');
		}

		if (!this.c.buttons.selected) {
			this.dom.selected.css('display', 'none');
		}

		// Render the options
		this._optionsTitle();

		Dom.s(document).on(
			'i18n.dt.' + namespace,
			function (e, settings: Context) {
				if (settings.language.datetime) {
					util.object.assignDeep(
						that.c.i18n,
						settings.language.datetime
					);
					that._optionsTitle();
				}
			}
		);

		// When attached to a hidden input, we always show the input picker, and
		// do so inline
		if (this.dom.input.attr('type') === 'hidden' || this.c.alwaysVisible) {
			this.dom.container.classAdd('inline');
			this.c.attachTo = 'input';

			this.val(this.dom.input.val(), false);
			this._show();
		}

		// Set the initial value
		if (last) {
			this.val(last, false);
		}

		// Trigger the display of the widget when clicking or focusing on the
		// input element
		this.dom.input
			.attr('autocomplete', 'off')
			.on('focus.datetime click.datetime', function () {
				// If already visible - don't do anything
				if (
					that.dom.container.isVisible() ||
					that.dom.input.is(':disabled')
				) {
					return;
				}

				// In case the value has changed by text
				last = that.dom.input.val();
				that.val(last, false);

				that._show();
			})
			.on('keyup.datetime', function () {
				// Update the calendar's displayed value as the user types
				that.val(that.dom.input.val(), false);
			});

		// Want to prevent the focus bubbling up the document to account for
		// focus capture in modals (e.g. Editor and Bootstrap). They can see the
		// focus as outside the modal and thus immediately blur focus on the
		// picker. Need to use a native addEL since jQuery changes the focusin
		// to focus for some reason! focusin bubbles, focus does not.
		this.dom.container.get(0).addEventListener('focusin', function (e) {
			e.stopPropagation();
		});

		// Main event handlers for input in the widget
		this.dom.container
			.on('change', 'select', function () {
				var select = Dom.s(this);
				var val = parseInt(select.val());

				if (select.classHas(classPrefix + '-month')) {
					// Month select
					that._correctMonth(that.s.display, val);
					that._setTitle();
					that._setCalander();
				}
				else if (select.classHas(classPrefix + '-year')) {
					// Year select
					that.s.display.setUTCFullYear(val);
					that._setTitle();
					that._setCalander();
				}
				else if (
					select.classHas(classPrefix + '-hours') ||
					select.classHas(classPrefix + '-ampm')
				) {
					// Hours - need to take account of AM/PM input if present
					if (that.s.parts.hours12) {
						var hours = parseInt(
							that.dom.container
								.find('.' + classPrefix + '-hours')
								.val(),
							10
						);
						var pm =
							that.dom.container
								.find('.' + classPrefix + '-ampm')
								.val() === 'pm';

						that.s.d.setUTCHours(
							hours === 12 && !pm
								? 0
								: pm && hours !== 12
								? hours + 12
								: hours
						);
					}
					else {
						that.s.d.setUTCHours(val);
					}

					that._setTime();
					that._writeOutput(true);

					onChange();
				}
				else if (select.classHas(classPrefix + '-minutes')) {
					// Minutes select
					that.s.d.setUTCMinutes(val);
					that._setTime();
					that._writeOutput(true);

					onChange();
				}
				else if (select.classHas(classPrefix + '-seconds')) {
					// Seconds select
					that.s.d.setSeconds(val);
					that._setTime();
					that._writeOutput(true);

					onChange();
				}

				that.dom.input.focus();
				that._position();
			})
			.on('click', function (e) {
				var d = that.s.d;
				var nodeName = e.target.nodeName.toLowerCase();
				var target =
					nodeName === 'span' ? e.target.parentNode : e.target;

				nodeName = target.nodeName.toLowerCase();

				if (nodeName === 'select') {
					return;
				}

				e.stopPropagation();

				if (nodeName === 'a') {
					e.preventDefault();

					if (Dom.s(target).classHas(classPrefix + '-clear')) {
						// Clear the value and don't change the display
						that.s.d = null;
						that.dom.input.val('');
						that._writeOutput();
						that._setCalander();
						that._setTime();

						onChange();
					}
					else if (Dom.s(target).classHas(classPrefix + '-today')) {
						// Don't change the value, but jump to the month
						// containing today
						that.s.display = new Date();

						that._setTitle();
						that._setCalander();
					}
					else if (
						Dom.s(target).classHas(classPrefix + '-selected')
					) {
						// Don't change the value, but jump to where the
						// selected value is
						that.s.display = new Date(that.s.d.getTime());

						that._setTitle();
						that._setCalander();
					}
				}
				if (nodeName === 'button') {
					var button = Dom.s(target);
					var parent = button.parent();

					if (
						parent.classHas('disabled') &&
						!parent.classHas('range')
					) {
						button.blur();
						return;
					}

					if (parent.classHas(classPrefix + '-iconLeft')) {
						// Previous month
						that.s.display.setUTCMonth(
							that.s.display.getUTCMonth() - 1
						);
						that._setTitle();
						that._setCalander();

						that.dom.input.focus();
					}
					else if (parent.classHas(classPrefix + '-iconRight')) {
						// Next month
						that._correctMonth(
							that.s.display,
							that.s.display.getUTCMonth() + 1
						);
						that._setTitle();
						that._setCalander();

						that.dom.input.focus();
					}
					else if (
						button.closest('.' + classPrefix + '-time').count()
					) {
						var val = button.data('value');
						var unit = button.data('unit');

						d = that._needValue();

						if (unit === 'minutes') {
							if (
								parent.classHas('disabled') &&
								parent.classHas('range')
							) {
								that.s.minutesRange = val as number;
								that._setTime();
								return;
							}
							else {
								that.s.minutesRange = null;
							}
						}

						if (unit === 'seconds') {
							if (
								parent.classHas('disabled') &&
								parent.classHas('range')
							) {
								that.s.secondsRange = val as number;
								that._setTime();
								return;
							}
							else {
								that.s.secondsRange = null;
							}
						}

						// Specific to hours for 12h clock
						if (val === 'am') {
							if (d.getUTCHours() >= 12) {
								val = d.getUTCHours() - 12;
							}
							else {
								return;
							}
						}
						else if (val === 'pm') {
							if (d.getUTCHours() < 12) {
								val = d.getUTCHours() + 12;
							}
							else {
								return;
							}
						}

						var set =
							unit === 'hours'
								? 'setUTCHours'
								: unit === 'minutes'
								? 'setUTCMinutes'
								: 'setSeconds';

						d[set](val);
						that._setCalander();
						that._setTime();
						that._writeOutput(true);
						onChange();
					}
					else {
						// Calendar click
						d = that._needValue();

						// Can't be certain that the current day will exist in
						// the new month, and likewise don't know that the
						// new day will exist in the old month, But 1 always
						// does, so we can change the month without worry of a
						// recalculation being done automatically by `Date`
						d.setUTCDate(1);
						d.setUTCFullYear(button.data('year'));
						d.setUTCMonth(button.data('month'));
						d.setUTCDate(button.data('day'));

						that._writeOutput(true);

						// Don't hide if there is a time picker, since we want to
						// be able to select a time as well.
						if (!that.s.parts.time) {
							// This is annoying but IE has some kind of async
							// behaviour with focus and the focus from the above
							// write would occur after this hide - resulting in
							// the calendar opening immediately
							setTimeout(function () {
								that._hide();
							}, 10);
						}
						else {
							that._setCalander();
							that._setTime();
						}

						onChange();
					}
				}
				else {
					// Click anywhere else in the widget - return focus to the
					// input element
					that.dom.input.focus();
				}
			});
	}

	/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
	 * Private
	 */

	/**
	 * Compare the date part only of two dates - this is made super easy by the
	 * toDateString method!
	 *
	 * @param a Date 1
	 * @param b Date 2
	 */
	private _compareDates(a: Date, b: Date) {
		// Can't use toDateString as that converts to local time
		// luxon uses different method names so need to be able to call them
		return this._isLuxon()
			? dateLib.DateTime.fromJSDate(a).toUTC().toISODate() ===
					dateLib.DateTime.fromJSDate(b).toUTC().toISODate()
			: this._dateToUtcString(a) === this._dateToUtcString(b);
	}

	/**
	 * Convert from one format to another
	 *
	 * @param val Value
	 * @param from Format to convert from. If null a `Date` must be given
	 * @param to Format to convert to. If null a `Date` will be returned
	 * @returns Converted value
	 */
	private _convert(
		val: string | Date,
		from: string | null,
		to: string | null
	) {
		if (!val) {
			return val;
		}

		if (!dateLib) {
			// Note that in here from and to can either be null or YYYY-MM-DD
			// They cannot be anything else
			if ((!from && !to) || (from && to)) {
				// No conversion
				return val;
			}
			else if (!from && val instanceof Date) {
				// Date in, string back
				return (
					val.getUTCFullYear() +
					'-' +
					this._pad(val.getUTCMonth() + 1) +
					'-' +
					this._pad(val.getUTCDate())
				);
			}
			else if (typeof val === 'string') {
				// (! to)
				// String in, date back
				var match = val.match(/(\d{4})\-(\d{2})\-(\d{2})/);
				return match
					? new Date(
							parseInt(match[1]),
							parseInt(match[2]) - 1,
							parseInt(match[3])
					  )
					: null;
			}
		}
		else if (this._isLuxon()) {
			// Luxon
			var dtLux =
				val instanceof Date
					? dateLib.DateTime.fromJSDate(val).toUTC()
					: dateLib.DateTime.fromFormat(val, from);

			if (!dtLux.isValid) {
				return null;
			}

			return to ? dtLux.toFormat(to) : dtLux.toJSDate();
		}
		else {
			// Moment / DayJS
			var dtMo =
				val instanceof Date
					? dateLib.utc(val, undefined, this.c.locale, this.c.strict)
					: dateLib(val, from, this.c.locale, this.c.strict);

			if (!dtMo.isValid()) {
				return null;
			}

			return to ? dtMo.format(to) : dtMo.toDate();
		}
	}

	/**
	 * When changing month, take account of the fact that some months don't have
	 * the same number of days. For example going from January to February you
	 * can have the 31st of Jan selected and just add a month since the date
	 * would still be 31, and thus drop you into March.
	 *
	 * @param date  Date - will be modified
	 * @param month Month to set
	 */
	private _correctMonth(date: Date, month: number) {
		var days = this._daysInMonth(date.getUTCFullYear(), month);
		var correctDays = date.getUTCDate() > days;

		date.setUTCMonth(month);

		if (correctDays) {
			date.setUTCDate(days);
			date.setUTCMonth(month);
		}
	}

	/**
	 * Get the number of days in a method. Based on
	 * http://stackoverflow.com/a/4881951 by Matti Virkkunen
	 *
	 * @param  year  Year
	 * @param  month Month (starting at 0)
	 */
	private _daysInMonth(year: number, month: number) {
		//
		var isLeap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
		var months = [
			31,
			isLeap ? 29 : 28,
			31,
			30,
			31,
			30,
			31,
			31,
			30,
			31,
			30,
			31
		];

		return months[month];
	}

	/**
	 * Create a new date object which has the UTC values set to the local time.
	 * This allows the local time to be used directly for the library which
	 * always bases its calculations and display on UTC.
	 *
	 * @param  s Date to "convert"
	 * @return Shifted date
	 */
	private _dateToUtc(s: Date) {
		if (!s) {
			return s;
		}

		return new Date(
			Date.UTC(
				s.getFullYear(),
				s.getMonth(),
				s.getDate(),
				s.getHours(),
				s.getMinutes(),
				s.getSeconds()
			)
		);
	}

	/**
	 * Create a UTC ISO8601 date part from a date object
	 *
	 * @param  d Date to "convert"
	 * @return ISO formatted date
	 */
	private _dateToUtcString(d: Date) {
		// luxon uses different method names so need to be able to call them
		return this._isLuxon()
			? dateLib.DateTime.fromJSDate(d).toUTC().toISODate()
			: d.getUTCFullYear() +
					'-' +
					this._pad(d.getUTCMonth() + 1) +
					'-' +
					this._pad(d.getUTCDate());
	}

	/**
	 * Hide the control and remove events related to its display
	 *
	 * @param destroy Flag to indicate that the instance is being destroyed
	 */
	private _hide(destroy = false) {
		if (
			!destroy &&
			(this.dom.input.attr('type') === 'hidden' || this.c.alwaysVisible)
		) {
			// Normally we wouldn't need to redraw the calander if it changes
			// and then hides, but if it is hidden, then we do need to make sure
			// that it is correctly up to date.
			this._setCalander();
			this._setTime();

			return;
		}

		var namespace = this.s.namespace;

		this.dom.container.detach();

		Dom.w.off('.' + namespace);
		Dom.s(document)
			.off('keydown.' + namespace)
			.off('keyup.' + namespace)
			.off('click.' + namespace);
		Dom.s('div.dt-scroll').off('scroll.' + namespace);
		Dom.s('div.DTE_Body_Content').off('scroll.' + namespace);
		Dom.s(this.dom.input.get(0).offsetParent).off('.' + namespace);
	}

	/**
	 * Convert a 24 hour value to a 12 hour value
	 *
	 * @param val 24 hour value
	 * @return 12 hour value
	 */
	private _hours24To12(val: number) {
		return val === 0 ? 12 : val > 12 ? val - 12 : val;
	}

	/**
	 * Generate the HTML for a single day in the calendar - this is basically
	 * and HTML cell with a button that has data attributes so we know what was
	 * clicked on (if it is clicked on) and a bunch of classes for styling.
	 *
	 * @param  {object} day Day object from the `_htmlMonth` method
	 * @return {string}     HTML cell
	 */
	private _htmlDay(day) {
		var classPrefix = this.c.classPrefix;
		if (day.empty) {
			return '<td class="' + classPrefix + '-empty"></td>';
		}

		var classes = ['selectable'];

		if (day.disabled) {
			classes.push('disabled');
		}

		if (day.today) {
			classes.push('now');
		}

		if (day.selected) {
			classes.push('selected');
		}

		return (
			'<td data-day="' +
			day.day +
			'" class="' +
			classes.join(' ') +
			'">' +
			'<button class="' +
			classPrefix +
			'-button ' +
			classPrefix +
			'-day" type="button" ' +
			'data-year="' +
			day.year +
			'" data-month="' +
			day.month +
			'" data-day="' +
			day.day +
			'">' +
			'<span>' +
			day.day +
			'</span>' +
			'</button>' +
			'</td>'
		);
	}

	/**
	 * Create the HTML for a month to be displayed in the calendar table.
	 *
	 * Based upon the logic used in Pikaday - MIT licensed
	 * Copyright (c) 2014 David Bushell
	 * https://github.com/dbushell/Pikaday
	 *
	 * @param  year  Year
	 * @param  month Month (starting at 0)
	 * @return Calendar month HTML
	 */
	private _htmlMonth(year: number, month: number) {
		var now = this._dateToUtc(new Date()),
			days = this._daysInMonth(year, month),
			before = new Date(Date.UTC(year, month, 1)).getUTCDay(),
			data = [],
			row = [];

		if (this.c.firstDay === null) {
			this.c.firstDay = this._localeFirstDay();
		}

		if (this.c.firstDay > 0) {
			before -= this.c.firstDay;

			if (before < 0) {
				before += 7;
			}
		}

		var cells = days + before,
			after = cells;

		while (after > 7) {
			after -= 7;
		}

		cells += 7 - after;

		var minDate = this.c.minDate;
		var maxDate = this.c.maxDate;

		if (minDate) {
			minDate.setUTCHours(0);
			minDate.setUTCMinutes(0);
			minDate.setSeconds(0);
		}

		if (maxDate) {
			maxDate.setUTCHours(23);
			maxDate.setUTCMinutes(59);
			maxDate.setSeconds(59);
		}

		for (var i = 0, r = 0; i < cells; i++) {
			var day = new Date(Date.UTC(year, month, 1 + (i - before))),
				selected = this.s.d ? this._compareDates(day, this.s.d) : false,
				today = this._compareDates(day, now),
				empty = i < before || i >= days + before,
				disabled =
					(minDate && day < minDate) || (maxDate && day > maxDate);

			var disableDays = this.c.disableDays;
			if (
				Array.isArray(disableDays) &&
				disableDays.includes(day.getUTCDay())
			) {
				disabled = true;
			}
			else if (
				typeof disableDays === 'function' &&
				disableDays(day) === true
			) {
				disabled = true;
			}

			var dayConfig = {
				day: 1 + (i - before),
				month: month,
				year: year,
				selected: selected,
				today: today,
				disabled: disabled,
				empty: empty
			};

			row.push(this._htmlDay(dayConfig));

			if (++r === 7) {
				if (this.c.showWeekNumber) {
					row.unshift(this._htmlWeekOfYear(i - before, month, year));
				}

				data.push('<tr>' + row.join('') + '</tr>');
				row = [];
				r = 0;
			}
		}

		var classPrefix = this.c.classPrefix;
		var className = classPrefix + '-table';
		if (this.c.showWeekNumber) {
			className += ' weekNumber';
		}

		// Show / hide month icons based on min/max
		if (minDate) {
			var underMin =
				minDate >= new Date(Date.UTC(year, month, 1, 0, 0, 0));

			this.dom.title
				.find('div.' + classPrefix + '-iconLeft')
				.css('display', underMin ? 'none' : 'block');
		}

		if (maxDate) {
			var overMax =
				maxDate < new Date(Date.UTC(year, month + 1, 1, 0, 0, 0));

			this.dom.title
				.find('div.' + classPrefix + '-iconRight')
				.css('display', overMax ? 'none' : 'block');
		}

		return (
			'<table class="' +
			className +
			'">' +
			'<thead>' +
			this._htmlMonthHead() +
			'</thead>' +
			'<tbody>' +
			data.join('') +
			'</tbody>' +
			'</table>'
		);
	}

	/**
	 * Create the calendar table's header (week days)
	 *
	 * @return {string} HTML cells for the row
	 */
	private _htmlMonthHead() {
		var a = [];
		var firstDay = this.c.firstDay;
		var i18n = this.c.i18n;

		// Take account of the first day shift
		var dayName = function (day) {
			day += firstDay;

			while (day >= 7) {
				day -= 7;
			}

			return i18n.weekdays[day];
		};

		// Empty cell in the header
		if (this.c.showWeekNumber) {
			a.push('<th></th>');
		}

		for (var i = 0; i < 7; i++) {
			a.push('<th>' + dayName(i) + '</th>');
		}

		return a.join('');
	}

	/**
	 * Create a cell that contains week of the year - ISO8601
	 *
	 * Based on https://stackoverflow.com/questions/6117814/ and
	 * http://techblog.procurios.nl/k/n618/news/view/33796/14863/
	 *
	 * @param  d Day of month
	 * @param  m Month of year (zero index)
	 * @param  y Year
	 * @return HTML string for a day
	 */
	private _htmlWeekOfYear(d: number, m: number, y: number) {
		var date = new Date(y, m, d, 0, 0, 0, 0);

		// First week of the year always has 4th January in it
		date.setDate(date.getDate() + 4 - (date.getDay() || 7));

		var oneJan = new Date(y, 0, 1);
		var weekNum = Math.ceil(
			(((date as any) - (oneJan as any)) / 86400000 + 1) / 7
		);

		return (
			'<td class="' + this.c.classPrefix + '-week">' + weekNum + '</td>'
		);
	}

	/**
	 * Determine if Luxon is being used
	 *
	 * @returns Flag for Luxon
	 */
	private _isLuxon() {
		return dateLib &&
			dateLib.DateTime &&
			dateLib.Duration &&
			dateLib.Settings
			? true
			: false;
	}

	/**
	 * Determine if Moment is being used
	 *
	 * @returns Flag for Moment
	 */
	private _isMoment() {
		return dateLib &&
			typeof dateLib.isDate === 'function' &&
			typeof dateLib.weekdays === 'function'
			? true
			: false;
	}

	/**
	 * Determine the first day of the week based on the current locale
	 */
	private _localeFirstDay() {
		let locale = this.c.locale;

		if (locale === 'en') {
			// Somewhat bonkers way to get the current locale, but there doesn't
			// seem to be a better way
			let browserLocale = new Intl.NumberFormat().resolvedOptions()
				.locale;

			// Not yet in Firefox
			if ((Intl as any).Locale) {
				let loc = new (Intl as any).Locale(browserLocale);

				if (loc.getWeekInfo) {
					let weekInfo = loc.getWeekInfo();

					// Safari and new Chrome have it as a function
					if (typeof weekInfo.firstDay === 'function') {
						return weekInfo.firstDay();
					}
					else if (typeof weekInfo.firstDay === 'number') {
						// Old Chrome as an accessor property
						return weekInfo.firstDay;
					}
				}
			}
		}

		if (this._isMoment()) {
			return dateLib.localeData(this.c.locale).firstDayOfWeek();
		}

		// There doesn't appear to be a way to do it in luxon

		// Default to Monday
		return 1;
	}

	/**
	 * Check if the instance has a date object value - it might be null.
	 * If is doesn't set one to now.
	 * @returns A Date object
	 */
	private _needValue() {
		if (!this.s.d) {
			this.s.d = this._dateToUtc(new Date());

			if (!this.s.parts.time) {
				this.s.d.setUTCHours(0);
				this.s.d.setUTCMinutes(0);
				this.s.d.setSeconds(0);
				this.s.d.setMilliseconds(0);
			}
		}

		return this.s.d;
	}

	/**
	 * Create option elements from a range in an array
	 *
	 * @param selector Class name unique to the select element to use
	 * @param values   Array of values
	 * @param labels Array of labels. If given must be the same length as the
	 *   values parameter.
	 */
	private _options(selector: string, values: any[], labels?: any[]) {
		if (!labels) {
			labels = values;
		}

		var select = this.dom.container.find(
			'select.' + this.c.classPrefix + '-' + selector
		);
		select.empty();

		for (var i = 0, ien = values.length; i < ien; i++) {
			select.append(
				Dom.c('option').attr('value', values[i]).text(labels[i])
			);
		}
	}

	/**
	 * Set an option and update the option's span pair (since the select element
	 * has opacity 0 for styling)
	 *
	 * @param selector Class name unique to the select element to use
	 * @param val      Value to set
	 */
	private _optionSet(selector: string, val: any) {
		var select = this.dom.container.find(
			'select.' + this.c.classPrefix + '-' + selector
		);
		var span = select.parent().children('span');

		select.val(val);

		var selected = select
			.find('option')
			.filter(e => (e as HTMLOptionElement).selected);

		span.html(
			selected.count() !== 0 ? selected.text() : this.c.i18n.unknown
		);
	}

	/**
	 * Create time options list.
	 *
	 * @param unit Time unit - hours, minutes or seconds
	 * @param count Count range - 12, 24 or 60
	 * @param val Existing value for this unit
	 * @param allowed Values allow for selection
	 * @param range Override range
	 */
	private _optionsTime(
		unit: string,
		count: number,
		val: number,
		allowed: number[],
		range?: number
	) {
		var classPrefix = this.c.classPrefix;
		var container = this.dom.container.find(
			'div.' + classPrefix + '-' + unit
		);
		var i, j;
		var render =
			count === 12
				? function (i) {
						return i;
				  }
				: this._pad;
		var className = classPrefix + '-table';
		var i18n = this.c.i18n;

		if (!container.count()) {
			return;
		}

		var a = '';
		var span = 10;
		var button = function (value, label, className = null) {
			// Shift the value for PM
			if (count === 12 && typeof value === 'number') {
				if (val >= 12) {
					value += 12;
				}

				if (value == 12) {
					value = 0;
				}
				else if (value == 24) {
					value = 12;
				}
			}

			var selected =
				val === value ||
				(value === 'am' && val < 12) ||
				(value === 'pm' && val >= 12)
					? 'selected'
					: '';

			if (
				typeof value === 'number' &&
				allowed &&
				!allowed.includes(value)
			) {
				selected += ' disabled';
			}

			if (className) {
				selected += ' ' + className;
			}

			return (
				'<td class="selectable ' +
				selected +
				'">' +
				'<button class="' +
				classPrefix +
				'-button ' +
				classPrefix +
				'-day" type="button" data-unit="' +
				unit +
				'" data-value="' +
				value +
				'">' +
				'<span>' +
				label +
				'</span>' +
				'</button>' +
				'</td>'
			);
		};

		if (count === 12) {
			// Hours with AM/PM
			a += '<tr>';

			for (i = 1; i <= 6; i++) {
				a += button(i, render(i));
			}
			a += button('am', i18n.amPm[0]);

			a += '</tr>';
			a += '<tr>';

			for (i = 7; i <= 12; i++) {
				a += button(i, render(i));
			}
			a += button('pm', i18n.amPm[1]);
			a += '</tr>';

			span = 7;
		}
		else if (count === 24) {
			// Hours - 24
			var c = 0;
			for (j = 0; j < 4; j++) {
				a += '<tr>';
				for (i = 0; i < 6; i++) {
					a += button(c, render(c));
					c++;
				}
				a += '</tr>';
			}

			span = 6;
		}
		else {
			// Minutes and seconds
			a += '<tr>';
			for (j = 0; j < 60; j += 10) {
				a += button(j, render(j), 'range');
			}
			a += '</tr>';

			// Slight hack to allow for the different number of columns
			a +=
				'</tbody></thead><table class="' +
				className +
				' ' +
				className +
				'-nospace"><tbody>';

			var start =
				range !== null
					? range
					: val === -1
					? 0
					: Math.floor(val / 10) * 10;

			a += '<tr>';
			for (j = start + 1; j < start + 10; j++) {
				a += button(j, render(j));
			}
			a += '</tr>';

			span = 6;
		}

		container
			.empty()
			.html(
				'<table class="' +
					className +
					'">' +
					'<thead><tr><th colspan="' +
					span +
					'">' +
					i18n[unit] +
					'</th></tr></thead>' +
					'<tbody>' +
					a +
					'</tbody>' +
					'</table>'
			);
	}

	/**
	 * Create the options for the month and year
	 */
	private _optionsTitle() {
		var i18n = this.c.i18n;
		var min = this.c.minDate;
		var max = this.c.maxDate;
		var minYear = min ? min.getFullYear() : null;
		var maxYear = max ? max.getFullYear() : null;

		var i =
			minYear !== null
				? minYear
				: new Date().getFullYear() - this.c.yearRange;
		var j =
			maxYear !== null
				? maxYear
				: new Date().getFullYear() + this.c.yearRange;

		this._options('month', this._range(0, 11), i18n.months);
		this._options('year', this._range(i, j));

		// Set the language strings in case any have changed
		this.dom.today.text(i18n.today).text(i18n.today);
		this.dom.selected.text(i18n.selected).text(i18n.selected);
		this.dom.clear.text(i18n.clear).text(i18n.clear);
		this.dom.previous
			.attr('title', i18n.previous)
			.children('button')
			.text(i18n.previous);
		this.dom.next
			.attr('title', i18n.next)
			.children('button')
			.text(i18n.next);
	}

	/**
	 * Simple two digit pad
	 *
	 * @param  {integer} i      Value that might need padding
	 * @return {string|integer} Padded value
	 */
	private _pad(i) {
		return i < 10 ? '0' + i : i;
	}

	/**
	 * Position the calendar to look attached to the input element
	 */
	private _position() {
		var offset =
			this.c.attachTo === 'input'
				? this.dom.input.position()
				: this.dom.input.offset();
		var container = this.dom.container;
		var inputHeight = this.dom.input.height('outer');

		if (container.classHas('inline')) {
			container.insertAfter(this.dom.input);
			return;
		}

		if (this.s.parts.date && this.s.parts.time && Dom.w.width() > 550) {
			container.classAdd('horizontal');
		}
		else {
			container.classRemove('horizontal');
		}

		if (this.c.attachTo === 'input') {
			container
				.css({
					top: offset.top + inputHeight + 'px',
					left: offset.left + 'px'
				})
				.insertAfter(this.dom.input);
		}
		else {
			container
				.css({
					top: offset.top + inputHeight + 'px',
					left: offset.left + 'px'
				})
				.appendTo('body');
		}

		var calHeight = container.height('outer');
		var calWidth = container.width('outer');
		var scrollTop = Dom.w.scrollTop();

		// Correct to the bottom
		if (offset.top + inputHeight + calHeight - scrollTop > Dom.w.height()) {
			var newTop = offset.top - calHeight;

			container.css('top', newTop < 0 ? '0' : newTop + 'px');
		}

		// Correct to the right
		if (calWidth + offset.left > Dom.w.width()) {
			var newLeft = Dom.w.width() - calWidth - 5;

			// Account for elements which are inside a position absolute element
			if (this.c.attachTo === 'input') {
				newLeft -= container
					.map(e => e.offsetParent as HTMLElement)
					.offset().left;
			}

			container.css('left', newLeft < 0 ? '0' : newLeft + 'px');
		}
	}

	/**
	 * Create a simple array with a range of values
	 *
	 * @param  start   Start value (inclusive)
	 * @param  end     End value (inclusive)
	 * @param  inc Increment value
	 * @return Created array
	 */
	private _range(start: number, end: number, inc = 1) {
		var a: number[] = [];

		for (var i = start; i <= end; i += inc) {
			a.push(i);
		}

		return a;
	}

	/**
	 * Redraw the calendar based on the display date - this is a destructive
	 * operation
	 */
	private _setCalander() {
		if (this.s.display) {
			this.dom.calendar
				.empty()
				.html(
					this._htmlMonth(
						this.s.display.getUTCFullYear(),
						this.s.display.getUTCMonth()
					)
				);
		}
	}

	/**
	 * Set the month and year for the calendar based on the current display date
	 */
	private _setTitle() {
		this._optionSet('month', this.s.display.getUTCMonth());
		this._optionSet('year', this.s.display.getUTCFullYear());
	}

	/**
	 * Set the time based on the current value of the widget
	 */
	private _setTime() {
		var that = this;
		var d = this.s.d;

		// luxon uses different method names so need to be able to call them.
		// This happens a few time later in this method too
		var luxDT = null;
		if (this._isLuxon()) {
			luxDT = dateLib.DateTime.fromJSDate(d).toUTC();
		}

		var hours = luxDT != null ? luxDT.hour : d ? d.getUTCHours() : -1;

		var allowed = function (prop) {
			// Backwards compt with `Increment` option
			return that.c[prop + 'Available']
				? that.c[prop + 'Available']
				: that._range(0, 59, that.c[prop + 'Increment']);
		};

		this._optionsTime(
			'hours',
			this.s.parts.hours12 ? 12 : 24,
			hours,
			this.c.hoursAvailable
		);
		this._optionsTime(
			'minutes',
			60,
			luxDT != null ? luxDT.minute : d ? d.getUTCMinutes() : -1,
			allowed('minutes'),
			this.s.minutesRange
		);
		this._optionsTime(
			'seconds',
			60,
			luxDT != null ? luxDT.second : d ? d.getSeconds() : -1,
			allowed('seconds'),
			this.s.secondsRange
		);
	}

	/**
	 * Show the widget and add events to the document required only while it
	 * is displayed
	 *
	 */
	private _show() {
		var that = this;
		var namespace = this.s.namespace;

		this._position();

		// Need to reposition on scroll
		Dom.w.on('scroll.' + namespace + ' resize.' + namespace, function () {
			that._position();
		});

		Dom.s('div.DTE_Body_Content').on('scroll.' + namespace, function () {
			that._position();
		});

		Dom.s('div.dt-scroll').on('scroll.' + namespace, function () {
			that._position();
		});

		var offsetParent = this.dom.input.get(0).offsetParent;

		if (offsetParent !== document.body) {
			Dom.s(offsetParent).on('scroll.' + namespace, function () {
				that._position();
			});
		}

		// On tab focus will move to a different field (no keyboard navigation
		// in the date picker - this might need to be changed).
		Dom.s(document).on('keydown.' + namespace, function (e) {
			if (
				that.dom.container.isVisible() &&
				(e.keyCode === 9 || // tab
					e.keyCode === 13) // return
			) {
				that._hide();
			}
		});

		// Esc is on keyup to allow Editor to know that the container was hidden
		// and thus not act on the esc itself.
		Dom.s(document).on('keyup.' + namespace, function (e) {
			if (that.dom.container.isVisible() && e.keyCode === 27) {
				// esc
				e.preventDefault();
				that._hide();
			}
		});

		clearTimeout(this.s.showTo);

		// We can't use blur to hide, as we want to keep the picker open while
		// to let the user select from it. But if focus is moved outside of of
		// the picker, then we auto hide.
		this.dom.input.on('blur', function (e) {
			that.s.showTo = setTimeout(function () {
				let name = document.activeElement.tagName.toLowerCase();

				if (document.activeElement === that.dom.input.get(0)) {
					return;
				}

				if (that.dom.container.find(document.activeElement).count()) {
					return;
				}

				if (['input', 'select', 'button'].includes(name)) {
					that.hide();
				}
			}, 10);
		});

		// Hide if clicking outside of the widget - but in a different click
		// event from the one that was used to trigger the show (bubble and
		// inline)
		setTimeout(function () {
			Dom.s(document).on('click.' + namespace, function (e) {
				if (
					!Dom.s(e.target)
						.closest(that.dom.container.get(0))
						.count() &&
					e.target !== that.dom.input.get(0)
				) {
					that._hide();
				}
			});
		}, 10);
	}

	/**
	 * Write the formatted string to the input element this control is attached
	 * to
	 */
	private _writeOutput(focus = false, change = true) {
		var date = this.s.d;
		var out = '';
		var input = this.dom.input;

		if (date) {
			out = this._convert(date, null, this.c.format);
		}

		input.val(out);

		if (change) {
			// Create a DOM synthetic event. Can't use $().trigger() as
			// that doesn't actually trigger non-jQuery event listeners
			var event = new Event('change', { bubbles: true });
			input.get(0).dispatchEvent(event);
		}

		if (input.attr('type') === 'hidden') {
			this.val(out, false);
		}

		if (focus) {
			input.focus();
		}
	}
}

// Global export - if no conflicts
// TODO is this right in the UDM?
if (!(window as any).DateTime) {
	(window as any).DateTime = DateTime;
}

// Make available via jQuery
if ((window as any).jQuery) {
	(window as any).jQuery.fn.dtDateTime = function (options) {
		return this.each(function () {
			new DateTime(this, options);
		});
	};
}

// Attach to DataTables
DataTable.DateTime = DateTime;
DataTable.use('datetime', DateTime);

// And to Editor (legacy)
if ((DataTable as any).Editor) {
	(DataTable as any).Editor.DateTime = DateTime;
}
