/*
 * Egroupware Calendar event widget
 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
 * @package etemplate
 * @subpackage api
 * @link http://www.egroupware.org
 * @author Nathan Gray
 * @version $Id$
 */


/*egw:uses
	/etemplate/js/et2_core_valueWidget;
*/

import {et2_register_widget, et2_widget, WidgetConfig} from "../../api/js/etemplate/et2_core_widget";
import {et2_valueWidget} from "../../api/js/etemplate/et2_core_valueWidget";
import {ClassWithAttributes} from "../../api/js/etemplate/et2_core_inheritance";
import {et2_action_object_impl, et2_DOMWidget} from "../../api/js/etemplate/et2_core_DOMWidget";
import {et2_calendar_daycol} from "./et2_widget_daycol";
import {et2_calendar_planner_row} from "./et2_widget_planner_row";

/**
 * Class for a single event, displayed in either the timegrid or planner view
 *
 * It is possible to directly provide all information directly, but calendar
 * uses egw.data for caching, so ID is all that is needed.
 *
 * Note that there are several pieces of information that have 'ID' in them:
 * - row_id - used by both et2_calendar_event and the nextmatch to uniquely
 *	identify a particular entry or entry ocurrence
 * - id - Recurring events may have their recurrence as a timestamp after their ID,
 *	such as '194:1453318200', or not.  It's usually (always?) the same as row ID.
 * - app_id - the ID according to the source application.  For calendar, this
 *	is the same as ID (but always with the recurrence), for other apps this is
 *	usually just an integer.  With app_id and app, you should be able to call
 *	egw.open() and get the specific entry.
 * - Events from other apps will have their app name prepended to their ID, such
 *	as 'infolog123', so app_id and id will be different for these events
 * - Cache ID is the same as other apps, and looks like 'calendar::<row_id>'
 * - The DOM ID for the containing div is event_<row_id>
 *
 * Events are expected to be added to either et2_calendar_daycol or
 * et2_calendar_planner_row rather than either et2_calendar_timegrid or
 * et2_calendar_planner directly.
 *
 */
export class et2_calendar_event extends et2_valueWidget implements et2_IDetachedDOM
{

	static readonly _attributes : any = {
		"value": {
			type: "any",
			default: et2_no_init
		},
		"onclick": {
			"description": "JS code which is executed when the element is clicked. " +
				"If no handler is provided, or the handler returns true and the event is not read-only, the " +
				"event will be opened according to calendar settings."
		}
	};
	private div: JQuery;
	private title: JQuery;
	private body: JQuery;
	private icons: JQuery;
	private _need_actions_linked: boolean = false;
	private _actionObject: egwActionObject;

	/**
	 * Constructor
	 */
	constructor(_parent, _attrs? : WidgetConfig, _child? : object)
	{
		// Call the inherited constructor
		super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_calendar_event._attributes, _child || {}));

		const event = this;

		// Main container
		this.div = jQuery(document.createElement("div"))
			.addClass("calendar_calEvent")
			.addClass(this.options.class)
			.css('width',this.options.width)
			.on('mouseenter', function() {
				// Bind actions on first mouseover for faster creation
				if(event._need_actions_linked)
				{
					event._copy_parent_actions();
				}
				// Tooltip
				if(!event._tooltipElem)
				{
					event.options.statustext_html = true;
					event.set_statustext(event._tooltip());
					if(event.statustext)
					{
						return event.div.trigger('mouseenter');
					}
				}
				// Hacky to remove egw's tooltip border and let the mouse in
				window.setTimeout(function() {
					jQuery('body .egw_tooltip')
						.css('border','none')
						.on('mouseenter', function() {
							event.div.off('mouseleave.tooltip');
							jQuery('body.egw_tooltip').remove();
							jQuery('body').append(this);
							jQuery(this).stop(true).fadeTo(400, 1)
								.on('mouseleave', function() {
									jQuery(this).fadeOut('400', function() {
										jQuery(this).remove();
										// Set up to work again
										event.set_statustext(event._tooltip());
									});
								});
						});

				},105);
			});
		this.title = jQuery(document.createElement('div'))
			.addClass("calendar_calEventHeader")
			.appendTo(this.div);
		this.body = jQuery(document.createElement('div'))
			.addClass("calendar_calEventBody")
			.appendTo(this.div);
		this.icons = jQuery(document.createElement('div'))
			.addClass("calendar_calEventIcons")
			.appendTo(this.title);

		this.setDOMNode(this.div[0]);
	}

	doLoadingFinished( )
	{
		super.doLoadingFinished();

		// Already know what is needed to hook to cache
		if(this.options.value && this.options.value.row_id)
		{
			egw.dataRegisterUID(
				'calendar::'+this.options.value.row_id,
				this._UID_callback,
				this,
				this.getInstanceManager().execId,
				this.id
			);
		}
		return true;
	}

	destroy( )
	{
		super.destroy();

		if(this._actionObject)
		{
			this._actionObject.remove();
			this._actionObject = null;
		}

		this.div.off();
		this.title.remove();
		this.title = null;
		this.body.remove();
		this.body = null;
		this.icons = null;
		this.div.remove();
		this.div = null;

		jQuery('body.egw_tooltip').remove();

		// Unregister, or we'll continue to be notified...
		if(this.options.value)
		{
			const old_app_id = this.options.value.row_id;
			egw.dataUnregisterUID('calendar::'+old_app_id,null,this);
		}
	}

	set_value( _value)
	{
		// Un-register for updates
		if(this.options.value)
		{
			var old_id = this.options.value.row_id;
			if(!_value || !_value.row_id || old_id !== _value.row_id)
			{
				egw.dataUnregisterUID('calendar::'+old_id,null,this);
			}
		}
		this.options.value = _value;

		// Register for updates
		const id = this.options.value.row_id;
		if(!old_id || old_id !== id)
		{
			egw.dataRegisterUID('calendar::'+id, this._UID_callback ,this,this.getInstanceManager().execId,this.id);
		}
		if(_value && !egw.dataHasUID('calendar::'+id))
		{
			egw.dataStoreUID('calendar::'+id, _value);
		}
	}

	/**
	 * Callback for changes in cached data
	 */
	_UID_callback(event)
	{
		// Copy to avoid changes, which may cause nm problems
		const value = event === null ? null : jQuery.extend({}, event);
		let parent = <et2_DOMWidget>this.getParent();
		let parent_owner = parent.getDOMNode(parent).dataset['owner'] || parent.getParent().options.owner;
		if(parent_owner.indexOf(',') >= 0)
		{
			parent_owner = parent_owner.split(',');
		}

		// Make sure id is a string, check values
		if(value)
		{
			this._values_check(value);
		}

		// Check for changing days in the grid view
		let state = this.getInstanceManager().app_obj.calendar.getState() || app.calendar.getState();
		if(!this._sameday_check(value) || !this._status_check(value, state.status_filter, parent_owner))
		{
			// May need to update parent to remove out-of-view events
			parent.removeChild(this);
			if(event === null && parent && parent.instanceOf(et2_calendar_daycol))
			{
				(<et2_calendar_daycol>parent)._out_of_view();
			}

			// This should now cease to exist, as new events have been created
			this.destroy();
			return;
		}

		// Copy to avoid changes, which may cause nm problems
		this.options.value = jQuery.extend({},value);

		if(this.getParent().options.date)
		{
			this.options.value.date = this.getParent().options.date;
		}

		// Let parent position - could also be et2_calendar_planner_row
		(<et2_calendar_daycol>this.getParent()).position_event(this);

		// Parent may remove this if the date isn't the same
		if(this.getParent())
		{
			this._update();
		}
	}

	/**
	 * Draw the event
	 */
	_update( )
	{

		// Update to reflect new information
		const event = this.options.value;

		const id = event.row_id ? event.row_id : event.id + (event.recur_type ? ':' + event.recur_date : '');
		const formatted_start = event.start.toJSON();

		this.set_id('event_' + id);
		if(this._actionObject)
		{
			this._actionObject.id = 'calendar::' + id;
		}

		this._need_actions_linked = !this.options.readonly;

		// Make sure category stuff is there
		// Fake it to use the cache / call - if already there, these will return
		// immediately.
		const im = this.getInstanceManager();
		et2_selectbox.cat_options({
				_type:'select-cat',
				getInstanceManager: function() {return im;}
			},
			{application:event.app||'calendar'}
		);

		// Need cleaning? (DnD helper removes content)
		// @ts-ignore
		if(!this.div.has(this.title).length)
		{
			this.div
				.empty()
				.append(this.title)
				.append(this.body);
		}
		if(!this.getParent().options.readonly && !this.options.readonly && this.div.droppable('instance'))
		{
			this.div
				// Let timegrid always get the drag
				.droppable('option','greedy',false);
		}
		// DOM nodes
		this.div
			// Set full day flag
			.attr('data-full_day', event.whole_day)

			// Put everything we need for basic interaction here, so it's available immediately
			.attr('data-id', event.id)
			.attr('data-app', event.app || 'calendar')
			.attr('data-app_id', event.app_id)
			.attr('data-start', formatted_start)
			.attr('data-owner', event.owner)
			.attr('data-recur_type', event.recur_type)
			.attr('data-resize', event.whole_day ? 'WD' : '' + (event.recur_type ? 'S':''))
			.attr('data-priority', event.priority)
			// Remove any category classes
			.removeClass(function(index, css) {
				return (css.match (/(^|\s)cat_\S+/g) || []).join(' ');
			})
			// Remove any status classes
			.removeClass(function(index, css) {
				return (css.match(/calendar_calEvent\S+/g) || []).join(' ');
			})
			.removeClass('calendar_calEventSmall')
			.addClass(event.class)
			.toggleClass('calendar_calEventPrivate', typeof event.private !== 'undefined' && event.private);
		this.options.class = event.class;
		const status_class = this._status_class();

		// Add category classes, if real categories are set
		if(event.category && event.category != '0')
		{
			const cats = event.category.split(',');
			for(let i = 0; i < cats.length; i++)
			{
				this.div.addClass('cat_' + cats[i]);
			}
		}

		this.div.toggleClass('calendar_calEventUnknown', event.participants[egw.user('account_id')] ? event.participants[egw.user('account_id')][0] === 'U' : false);
		this.div.addClass(status_class);

		this.body.toggleClass('calendar_calEventBodySmall', event.whole_day_on_top || false);

		// Header
		const title = !event.is_private ? egw.htmlspecialchars(event['title']) : egw.lang('private');

		this.title
			.html('<span class="calendar_calTimespan">'+this._get_timespan(event) + '<br /></span>')
			.append('<span class="calendar_calEventTitle">'+title+'</span>');

		// Colors - don't make them transparent if there is no color
		// @ts-ignore
		if(jQuery.Color("rgba(0,0,0,0)").toRgbaString() != jQuery.Color(this.div,'background-color').toRgbaString())
		{
			// Most statuses use colored borders
			this.div.css('border-color',this.div.css('background-color') );
		}

		this.icons.appendTo(this.title)
			.html(this._icons().join(''));

		// Body
		if(event.whole_day_on_top)
		{
			this.body.html(title);
		}
		else
		{
			// @ts-ignore
			const start_time = jQuery.datepicker.formatTime(
				egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm",
				{
					hour: event.start_m / 60,
					minute: event.start_m % 60,
					seconds: 0,
					timezone: 0
				},
				{"ampm": (egw.preference("timeformat") === "12")}
			).trim();

			this.body
				.html('<span class="calendar_calEventTitle">'+title+'</span>')
				.append('<span class="calendar_calTimespan">'+start_time + '</span>');
			if(this.options.value.description.trim())
			{
				this.body
					.append('<p>'+egw.htmlspecialchars(this.options.value.description)+'</p>');
			}
		}

		// Clear tooltip for regeneration
		this.set_statustext('');

		// Height specific section
		// This can take an unreasonable amount of time if parent is hidden
		if(jQuery((<et2_DOMWidget>this.getParent()).getDOMNode(this)).is(':visible'))
		{
			this._small_size();
		}
	}

	/**
	 * Calculate display variants for when event is too short for full display
	 *
	 * Display is based on the number of visible lines, calculated off the header
	 * height:
	 * 1 - show just the event title, with ellipsis
	 * 2 - Show timespan and title, with ellipsis
	 * > 4 - Show description as well, truncated to fit
	 */
	_small_size( )
	{

		if(this.options.value.whole_day_on_top) return;

		// Skip for planner view, it's always small
		if(this.getParent() && this.getParent().instanceOf(et2_calendar_planner_row)) return;

		// Pre-calculation reset
		this.div.removeClass('calendar_calEventSmall');
		this.body.css('height', 'auto');

		const line_height = parseFloat(this.div.css('line-height'));
		let visible_lines = Math.floor(this.div.innerHeight() / line_height);

		if(!this.title.height())
		{
			// Handle sizing while hidden, such as when calendar is not the active tab
			visible_lines = Math.floor(egw.getHiddenDimensions(this.div).h / egw.getHiddenDimensions(this.title).h);
		}
		visible_lines = Math.max(1,visible_lines);

		if(this.getParent() && this.getParent().instanceOf(et2_calendar_daycol))
		{
			this.div.toggleClass('calendar_calEventSmall',visible_lines < 4);
			this.div
				.attr('data-visible_lines', visible_lines);
		}
		else if (this.getParent() && this.getParent().instanceOf(et2_calendar_planner_row))
		{
			// Less than 8 hours is small
			this.div.toggleClass('calendar_calEventSmall',this.options.value.end.valueOf() - this.options.value.start.valueOf() < 28800000);
		}


		if(this.body.height() > this.div.height() - this.title.height() && visible_lines >= 4)
		{
			this.body.css('height', Math.floor((visible_lines-1)*line_height - this.title.height()) + 'px');
		}
		else
		{
			this.body.css('height', '');
		}
	}

	/**
	 * Examines the participants & returns CSS classname for status
	 *
	 * @returns {String}
	 */
	_status_class( )
	{
		let status_class = 'calendar_calEventAllAccepted';
		for(let id in this.options.value.participants)
		{
			let status = this.options.value.participants[id];

			status = et2_calendar_event.split_status(status);

			switch (status)
			{
				case 'A':
				case '':	// app without status
					break;
				case 'U':
					status_class = 'calendar_calEventSomeUnknown';
					return status_class;	// break for
				default:
					status_class = 'calendar_calEventAllAnswered';
					break;
			}
		}
		return status_class;
	}

	/**
	 * Create tooltip shown on hover
	 *
	 * @return {String}
	 */
	_tooltip( )
	{
		if(!this.div || !this.options.value || !this.options.value.app_id) return '';

		const border = this.div.css('borderTopColor');
		const bg_color = this.div.css('background-color');
		const header_color = this.title.css('color');
		const timespan = this._get_timespan(this.options.value);
		const parent = this.getParent() instanceof et2_calendar_daycol ? (<et2_calendar_daycol>this.getParent()) : (<et2_calendar_planner_row>this.getParent());

		parent.date_helper.set_value(this.options.value.start.valueOf ? new Date(this.options.value.start) : this.options.value.start);
		const start = parent.date_helper.input_date.val();
		parent.date_helper.set_value(this.options.value.end.valueOf ? new Date(this.options.value.end) : this.options.value.end);
		const end = parent.date_helper.input_date.val();

		const times = !this.options.value.multiday ?
			'<span class="calendar_calEventLabel">' + this.egw().lang('Time') + '</span>:' + timespan :
			'<span class="calendar_calEventLabel">' + this.egw().lang('Start') + '</span>:' + start + ' ' +
			'<span class="calendar_calEventLabel">' + this.egw().lang('End') + '</span>:' + end;
		let cat_label: (string | string[]) = '';
		if(this.options.value.category)
		{
			const cat = et2_createWidget('select-cat', {'readonly': true}, this);
			cat.set_value(this.options.value.category);
			cat_label = this.options.value.category.indexOf(',') <= 0 ? cat.span.text() : [];
			if(typeof cat_label != 'string')
			{
				cat.span.children().each(function() {
					(<string[]>cat_label).push(jQuery(this).text());
				});
				cat_label = cat_label.join(', ');
			}
			cat.destroy();
		}

		// Location + Videoconference
		let location = '';
		if(this.options.value.location || this.options.value['##videoconference'])
		{
			location += '<p><span class="calendar_calEventLabel">' + this.egw().lang('Location') + '</span>:' +
				egw.htmlspecialchars(this.options.value.location);
			if(this.options.value['##videoconference'])
			{
				// Click handler is set in _bind_videoconference()
				location += (this.options.value.location.trim() ? '<br />' : '') +
					'<span data-videoconference="'+this.options.value['##videoconference']+ '">' + this.egw().lang('Video conference') +
					'<img src="' + this.egw().image('videoconference', 'calendar') + '"/></span>';
				this._bind_videoconference();
			}
			location += '</p>';
		}

		// Participants
		let participants = '';
		if(this.options.value.participant_types[''])
		{
			participants += this.options.value.participant_types[''].join("<br />");
		}
		for(let type_name in this.options.value.participant_types)
		{
			if(type_name)
			{
				participants += '</p><p><span class="calendar_calEventLabel">'+type_name+'</span>:<br />';
				participants += this.options.value.participant_types[type_name].join("<br />");
			}
		}

		return '<div class="calendar_calEventTooltip ' + this._status_class() +' '+ this.options.class +
			'" style="border-color: '+border+'; background-color: '+bg_color+';">'+
			'<div class="calendar_calEventHeaderSmall">'+
				'<font style="color:'+header_color+'">'+timespan+'</font>'+
				this.icons[0].outerHTML+
			'</div>'+
			'<div class="calendar_calEventBody">'+
				'<p style="margin: 0px;">'+
				'<span class="calendar_calEventTitle">'+egw.htmlspecialchars(this.options.value.title)+'</span><br>'+
				egw.htmlspecialchars(this.options.value.description)+'</p>'+
				'<p style="margin: 2px 0px;">'+times+'</p>'+
				location +
				(cat_label ? '<p><span class="calendar_calEventLabel">'+this.egw().lang('Category') + '</span>:' + cat_label +'</p>' : '')+
				'<p><span class="calendar_calEventLabel">'+this.egw().lang('Participants')+'</span>:<br />'+
					participants + '</p>'+ this._participant_summary(this.options.value.participants) +
			'</div>'+
		'</div>';
	}

	/**
	 * Generate participant summary line
	 *
	 * @returns {String}
	 */
	_participant_summary(participants)
	{
		if( Object.keys(this.options.value.participants).length < 2)
		{
			return '';
		}

		const participant_status = {A: 0, R: 0, T: 0, U: 0, D: 0};
		const status_label = {A: 'accepted', R: 'rejected', T: 'tentative', U: 'unknown', D: 'delegated'};
		const participant_summary = Object.keys(this.options.value.participants).length + ' ' + this.egw().lang('Participants') + ': ';
		const status_totals = [];

		for(let id in this.options.value.participants)
		{
			var status = this.options.value.participants[id].substr(0,1);
			participant_status[status]++;
		}
		for(let status in participant_status)
		{
			if(participant_status[status] > 0)
			{
				status_totals.push(participant_status[status] + ' ' + this.egw().lang(status_label[status]));
			}
		}
		return participant_summary + status_totals.join(', ');
	}

	/**
	 * Get actual icons from list
	 */
	_icons( ) : string[]
	{
		const icons = [];

		if(this.options.value.is_private)
		{
			// Hide everything
			icons.push('<img src="'+this.egw().image('private','calendar')+'"/>');
		}
		else
		{
			if(this.options.value.app !== 'calendar')
			{
				icons.push('<img src="'+this.egw().image('navbar',this.options.value.app)+'" title="'+this.egw().lang(this.options.value.app)+'"/>');
			}
			if(this.options.value.priority == 3)
			{
				icons.push('<img src="'+this.egw().image('high','calendar')+'" title="'+this.egw().lang('high priority')+'"/>');
			}
			if(this.options.value.public == '0')
			{
				// Show private flag
				icons.push('<img src="'+this.egw().image('private','calendar')+'"/>');
			}
			if(this.options.value['recur_type'])
			{
				icons.push('<img src="'+this.egw().image('recur','calendar')+'" title="'+this.egw().lang('recurring event')+'"/>');
			}
			// icons for single user, multiple users or group(s) and resources
			const single = '<img src="' + this.egw().image('single', 'calendar') + '" title="' + '"/>';
			const multiple = '<img src="' + this.egw().image('users', 'calendar') + '" title="' + '"/>';
			for(const uid in this.options.value['participants'])
			{
				// @ts-ignore
				if(Object.keys(this.options.value.participants).length == 1 && !isNaN(uid))
				{
					icons.push(single);
					break;
				}
				// @ts-ignore
				if(!isNaN(uid) && icons.indexOf(multiple) === -1)
				{
					icons.push(multiple);
				}
				/*
				 * TODO: resource icons
				elseif(!isset($icons[$uid[0]]) && isset($this->bo->resources[$uid[0]]) && isset($this->bo->resources[$uid[0]]['icon']))
				{
				 	$icons[$uid[0]] = html::image($this->bo->resources[$uid[0]]['app'],
				 		($this->bo->resources[$uid[0]]['icon'] ? $this->bo->resources[$uid[0]]['icon'] : 'navbar'),
				 		lang($this->bo->resources[$uid[0]]['app']),
				 		'width="16px" height="16px"');
				}
				*/
			}

			if(this.options.value.alarm && !jQuery.isEmptyObject(this.options.value.alarm) && !this.options.value.is_private)
			{
				icons.push('<img src="'+this.egw().image('alarm','calendar')+'" title="'+this.egw().lang('alarm')+'"/>');
			}
			if(this.options.value.participants[egw.user('account_id')] && this.options.value.participants[egw.user('account_id')][0] == 'U')
			{
				icons.push('<img src="'+this.egw().image('needs-action','calendar')+'" title="'+this.egw().lang('Needs action')+'"/>');
			}
			if(this.options.value["##videoconference"])
			{
				icons.push('<img src="' + this.egw().image('videoconference','calendar') + '" title="' + this.egw().lang('video conference') + '"/>');
			}
		}

		// Always include non-blocking, regardless of privacy
		if(this.options.value.non_blocking)
		{
			icons.push('<img src="'+this.egw().image('nonblocking','calendar')+'" title="'+this.egw().lang('non blocking')+'"/>');
		}
		return icons;
	}

	/**
	 * Bind the click handler for opening the video conference
	 *
	 * Tooltips are placed in the DOM directly in the body, managed by egw.
	 */
	_bind_videoconference()
	{
		let vc_event = 'click.calendar_videoconference';
		jQuery('body').off(vc_event)
			.on(vc_event, '[data-videoconference]',function(event) {
				app.calendar.joinVideoConference(this.dataset.videoconference);
			});
	}
	/**
	 * Get a text representation of the timespan of the event.  Either start
	 * - end, or 'all day'
	 *
	 * @param {Object} event Event to get the timespan for
	 * @param {number} event.start_m Event start, in minutes from midnight
	 * @param {number} event.end_m Event end, in minutes from midnight
	 *
	 * @return {string} Timespan
	 */
	_get_timespan( event)
	{
		let timespan = '';
		if (event['start_m'] === 0 && event['end_m'] >= 24*60-1)
		{
			if (event['end_m'] > 24*60)
			{
				// @ts-ignore
				timespan = jQuery.datepicker.formatTime(
					egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm",
					{
						hour: event.start_m / 60,
						minute: event.start_m % 60,
						seconds: 0,
						timezone: 0
					},
					{"ampm": (egw.preference("timeformat") === "12")}
					// @ts-ignore
				).trim()+' - '+jQuery.datepicker.formatTime(
					egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm",
					{
						hour: event.end_m / 60,
						minute: event.end_m % 60,
						seconds: 0,
						timezone: 0
					},
					{"ampm": (egw.preference("timeformat") === "12")}
				).trim();
			}
			else
			{
				timespan = this.egw().lang('Whole day');
			}
		}
		else
		{
			let duration : string | number = event.multiday ?
				(event.end - event.start) / 60000 :
				(event.end_m - event.start_m);
			duration = Math.floor(duration/60) + this.egw().lang('h')+(duration%60 ? duration%60 : '');

			// @ts-ignore
			timespan = jQuery.datepicker.formatTime(
				egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm",
				{
					hour: event.start_m / 60,
					minute: event.start_m % 60,
					seconds: 0,
					timezone: 0
				},
				{"ampm": (egw.preference("timeformat") === "12")}
			).trim();

			// @ts-ignore
			timespan += ' - ' + jQuery.datepicker.formatTime(
				egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm",
				{
					hour: event.end_m / 60,
					minute: event.end_m % 60,
					seconds: 0,
					timezone: 0
				},
				{"ampm": (egw.preference("timeformat") === "12")}
			).trim();

			timespan += ': ' + duration;
		}
		return timespan;
	}

	/**
	 * Make sure event data has all proper values, and format them as expected
	 * @param {Object} event
	 */
	_values_check(event)
	{
		// Make sure ID is a string
		if(event.id)
		{
			event.id = ''+event.id;
		}

		// Parent might be a daycol or a planner_row
		let parent = <et2_calendar_daycol>this.getParent();

		// Use dates as objects
		if(typeof event.start !== 'object')
		{
			parent.date_helper.set_value(event.start);
			event.start = new Date(parent.date_helper.getValue());
		}
		if(typeof event.end !== 'object')
		{
			parent.date_helper.set_value(event.end);
			event.end = new Date(parent.date_helper.getValue());
		}

		// We need minutes for durations
		if(typeof event.start_m === 'undefined')
		{
			event.start_m = event.start.getUTCHours() * 60 + event.start.getUTCMinutes();
			event.end_m = event.end.getUTCHours() * 60 + event.end.getUTCMinutes();
		}
		if(typeof event.multiday === 'undefined')
		{
			event.multiday = (event.start.getUTCFullYear() !== event.end.getUTCFullYear() ||
				event.start.getUTCMonth() !== event.end.getUTCMonth() ||
				event.start.getUTCDate() != event.end.getUTCDate());
		}
		if(!event.start.getUTCHours() && !event.start.getUTCMinutes() && event.end.getUTCHours() == 23 && event.end.getUTCMinutes() == 59)
		{
			event.whole_day_on_top = (event.non_blocking && event.non_blocking != '0');
		}
	}

	/**
	 * Check to see if the provided event information is for the same date as
	 * what we're currently expecting, and that it has not been changed.
	 *
	 * If the date has changed, we adjust the associated daywise caches to move
	 * the event's ID to where it should be.  This check allows us to be more
	 * directly reliant on the data cache, and less on any other control logic
	 * elsewhere first.
	 *
	 * @param {Object} event Map of event data from cache
	 * @param {string} event.date For non-recurring, single day events, this is
	 *	the date the event is on.
	 * @param {string} event.start Start of the event (used for multi-day events)
	 * @param {string} event.end End of the event (used for multi-day events)
	 *
	 * @return {Boolean} Provided event data is for the same date
	 */
	_sameday_check(event)
	{
		// Event somehow got orphaned, or deleted
		if(!this.getParent() || event === null)
		{
			return false;
		}

		// Also check participants against owner
		const owner_match = et2_calendar_event.owner_check(event, this.getParent());

		// Simple, same day
		if(owner_match && this.options.value.date && event.date == this.options.value.date)
		{
			return true;
		}

		// Multi-day non-recurring event spans days - date does not match
		const event_start = new Date(event.start);
		const event_end = new Date(event.end);
		const parent = this.getParent();
		if(owner_match && (parent instanceof et2_calendar_daycol) && parent.getDate() >= event_start && parent.getDate() <= event_end)
		{
			return true;
		}

		// Delete all old actions
		if(this._actionObject)
		{
			this._actionObject.clear();
			this._actionObject.unregisterActions();
			this._actionObject = null;
		}

		// Update daywise caches
		const new_cache_id = CalendarApp._daywise_cache_id(event.date, this.getParent().options.owner);
		let new_daywise: any = egw.dataGetUIDdata(new_cache_id);
		new_daywise = new_daywise && new_daywise.data ? new_daywise.data : [];
		let old_cache_id = '';
		if(this.options.value && this.options.value.date)
		{
			old_cache_id = CalendarApp._daywise_cache_id(this.options.value.date,parent.options.owner);
		}

		if(new_cache_id != old_cache_id)
		{
			let old_daywise: any = egw.dataGetUIDdata(old_cache_id);
			old_daywise = old_daywise && old_daywise.data ? old_daywise.data : [];
			old_daywise.splice(old_daywise.indexOf(this.options.value.row_id),1);
			egw.dataStoreUID(old_cache_id,old_daywise);

			if (new_daywise.indexOf(event.row_id) < 0)
			{
				new_daywise.push(event.row_id);
			}
			if(egw.dataHasUID(new_cache_id))
			{
				egw.dataStoreUID(new_cache_id, new_daywise);
			}
		}

		return false;
	}

	/**
	 * Check that the event passes the given status filter.
	 * Status filter is set in the sidebox and used when fetching several events, but if user changes their status
	 * for an event, it may no longer match and have to be removed.
	 *
	 * @param event
	 * @param filter
	 * @param owner The owner of the target / parent, not the event owner
	 * @private
	 */
	_status_check(event, filter: string, owner: string | string[]): boolean
	{
		if(!owner || !event)
		{
			return false;
		}

		// If we're doing a bunch, just one passing is enough
		if(typeof owner !== "string")
		{
			let pass = false;
			for (let j = 0; j < owner.length && pass == false; j++)
			{
				pass = pass || this._status_check(event, filter, owner[j]);
			}
			return pass;
		}

		// Show also events just owned by selected user
		// Group members can be owner too, those get handled when we check group memberships below
		if(filter == 'owner' && owner == event.owner)
		{
			return true;
		}

		// Get the relevant participant
		let participant = event.participants[owner];

		// If filter says don't look in groups, skip it all
		if(!participant && filter === 'no-enum-groups')
		{
			return false;
		}

		// Couldn't find the current owner in the participant list, check groups & resources
		if(!participant)
		{
			let options: any = null;
			if(app.calendar && app.calendar.sidebox_et2 && app.calendar.sidebox_et2.getWidgetById('owner'))
			{
				options = app.calendar.sidebox_et2.getWidgetById('owner').taglist.getSelection();
			}
			if((isNaN(parseInt(owner)) || parseInt(owner) < 0) && options && typeof options.find == "function")
			{
				let resource = options.find(function (element)
                {
                    return element.id == owner;
                }) || {};
				let matching_participant =  typeof resource.resources == "undefined" ?
					resource :
					resource?.resources.filter(id => typeof event.participants[id] != "undefined");
				if(matching_participant.length > 0)
				{
					return this._status_check(event, filter, matching_participant);
				}
				else if (filter == 'owner' && resource && resource.resources && resource.resources.indexOf(event.owner))
				{
					// owner param was a group but event is owned by someone in that group
					return true;
				}
			}
		}

		let status = et2_calendar_event.split_status(participant);

		switch (filter)
		{
			default:
			case 'all':
				return true;
			case 'default': // Show all status, but rejected
				return status !== 'R';
			case 'accepted': //Show only accepted events
				return status === 'A'
			case 'unknown': // Show only invitations, not yet accepted or rejected
				return status === 'U';
			case 'tentative': // Show only tentative accepted events
				return status === 'T';
			case 'delegated': // Show only delegated events
				return status === 'D';
			case 'rejected': // Show only rejected events
				return status === 'R';
			// Handled above
			//case 'owner': // Show also events just owned by selected user
			case 'hideprivate': // Show all events, as if they were private
				// handled server-side
				return true;
			case 'showonlypublic': // Show only events flagged as public, -not checked as private
				return event.public == '1';
			// Handled above
			// case 'no-enum-groups': // Do not include events of group members
			case 'not-unknown': // Show all status, but unknown
				return status !== 'U';
			case 'deleted': // Show events that have been deleted
				return event.deleted;
		}
	}

	attachToDOM()
	{
		let result = super.attachToDOM();

		// Remove the binding for the click handler, unless there's something
		// custom here.
		if(!this.onclick)
		{
			jQuery(this.node).off("click");
		}
		return result;
	}

	/**
	 * Click handler calling custom handler set via onclick attribute to this.onclick.
	 * All other handling is done by the timegrid widget.
	 *
	 * @param {Event} _ev
	 * @returns {boolean}
	 */
	click( _ev)
	{
		let result = true;
		if(typeof this.onclick == 'function')
		{
			// Make sure function gets a reference to the widget, splice it in as 2. argument if not
			const args = Array.prototype.slice.call(arguments);
			if(args.indexOf(this) == -1) args.splice(1, 0, this);

			result = this.onclick.apply(this, args);
		}
		return result;
	}

	/**
	 * Show the recur prompt for this event
	 *
	 * Calls et2_calendar_event.recur_prompt with this event's value.
	 *
	 * @param {et2_calendar_event~prompt_callback} callback
	 * @param {Object} [extra_data]
	 */
	recur_prompt(callback, extra_data)
	{
		et2_calendar_event.recur_prompt(this.options.value,callback,extra_data);
	}

	/**
	 * Show the series split prompt for this event
	 *
	 * Calls et2_calendar_event.series_split_prompt with this event's value.
	 *
	 * @param {et2_calendar_event~prompt_callback} callback
	 */
	series_split_prompt(callback)
	{
		et2_calendar_event.series_split_prompt(this.options.value,this.options.value.recur_date, callback);
	}

	/**
	 * Copy the actions set on the parent, apply them to self
	 *
	 * This can take a while to do, so we try to do it only when needed - on mouseover
	 */
	_copy_parent_actions()
	{
		// Copy actions set in parent
		if(!this.options.readonly && !this.getParent().options.readonly)
		{
			let action_parent : et2_widget = this;
			while(action_parent != null && !action_parent.options.actions &&
				!(action_parent instanceof et2_container)
			)
			{
				action_parent = action_parent.getParent();
			}
			try {
				this._link_actions(action_parent.options.actions||{});
				this._need_actions_linked = false;
			} catch (e) {
				// something went wrong, but keep quiet about it
			}
		}
	}

	/**
	 * Link the actions to the DOM nodes / widget bits.
	 *
	 * @param {object} actions {ID: {attributes..}+} map of egw action information
	 */
	_link_actions(actions)
	{
		if(!this._actionObject)
		{
			// Get the top level element - timegrid or so
			var objectManager = this.getParent()._actionObject || this.getParent().getParent()._actionObject ||
			   egw_getAppObjectManager(true).getObjectById(this.getParent().getParent().getParent().id) || egw_getAppObjectManager(true);
			this._actionObject = objectManager.getObjectById('calendar::'+this.options.value.row_id);
		}

		if (this._actionObject == null) {
			// Add a new container to the object manager which will hold the widget
			// objects
			this._actionObject = objectManager.insertObject(false, new egwActionObject(
				'calendar::'+this.options.value.row_id, objectManager, et2_calendar_event.et2_event_action_object_impl(this,this.getDOMNode()),
				this._actionManager || objectManager.manager.getActionById('calendar::'+this.options.value.row_id) || objectManager.manager
			));
		}
		else
		{
			this._actionObject.setAOI(et2_calendar_event.et2_event_action_object_impl(this, this.getDOMNode(this)));
		}

		// Delete all old objects
		this._actionObject.clear();
		this._actionObject.unregisterActions();

		// Go over the widget & add links - this is where we decide which actions are
		// 'allowed' for this widget at this time
		const action_links = this._get_action_links(actions);
		action_links.push('egw_link_drag');
		action_links.push('egw_link_drop');
		if(this._actionObject.parent.getActionLink('invite'))
		{
			action_links.push('invite');
		}
		this._actionObject.updateActionLinks(action_links);
	}

	/**
	 * Code for implementing et2_IDetachedDOM
	 *
	 * @param {array} _attrs array to add further attributes to
	 */
	getDetachedAttributes( _attrs)
	{

	}

	getDetachedNodes( )
	{
		return [this.getDOMNode()];
	}

	setDetachedAttributes( _nodes, _values)
	{

	}

	// Static class stuff
	/**
	 * Check event owner against a parent object
	 *
	 * As an event is edited, its participants may change.  Also, as the state
	 * changes we may change which events are displayed and show the same event
	 * in several places for different users.  Here we check the event participants
	 * against an owner value (which may be an array) to see if the event should be
	 * displayed or included.
	 *
	 * @param {Object} event - Event information
	 * @param {et2_widget_daycol|et2_widget_planner_row} parent - potential parent object
	 *	that has an owner option
	 * @param {boolean} [owner_too] - Include the event owner in consideration, or only
	 *	event participants
	 *
	 * @return {boolean} Should the event be displayed
	 */
	static owner_check(event, parent, owner_too?)
	{
		let owner_match = true;
		let state = parent.getInstanceManager()?.app_obj.calendar.state || app.calendar?.state || {}
		if(typeof owner_too === 'undefined' && state.status_filter)
		{
			owner_too = state.status_filter === 'owner';
		}
		let options : any = null;
		if(app.calendar && app.calendar.sidebox_et2 && app.calendar.sidebox_et2.getWidgetById('owner'))
		{
			options = app.calendar.sidebox_et2.getWidgetById('owner').taglist.getSelection();
		}
		else
		{
			options = parent.getArrayMgr("sel_options").getRoot().getEntry('owner');
		}
		if(event.participants && typeof parent.options.owner != 'undefined' && parent.options.owner.length > 0)
		{
			var parent_owner = jQuery.extend([], typeof parent.options.owner !== 'object' ?
				[parent.options.owner] :
				parent.options.owner);
			owner_match = false;
			const length = parent_owner.length;
			for(var i = 0; i < length; i++ )
			{
				// Handle groups & grouped resources like mailing lists, they won't match so
				// we need the list - pull it from sidebox owner
				if((isNaN(parent_owner[i]) || parent_owner[i] < 0) && options && typeof options.find == "function")
				{
					var resource = options.find(function(element) {return element.id == parent_owner[i];}) || {};
					if(resource && resource.resources)
					{
						parent_owner.splice(i,1);
						i--;
						parent_owner = parent_owner.concat(resource.resources);

					}
				}
			}
			let participants = jQuery.extend([], Object.keys(event.participants));
			for(var i = 0; i < participants.length; i++ )
			{
				const id = participants[i];
				// Expand group invitations
				if (parseInt(id) < 0)
				{
					// Add in groups, if we can get them from options, great
					var resource;
					if(options && options.find && (resource = options.find(function(element) {return element.id === id;})) && resource.resources)
					{
						participants = participants.concat(resource.resources);
					}
					else
					{
						// Add in groups, if we can get them (this is asynchronous)
						egw.accountData(id,'account_id',true,function(members) {
							participants = participants.concat(Object.keys(members));
						}, this);
					}
				}
				if(parent.options.owner == id ||
					parent_owner.indexOf &&
					parent_owner.indexOf(id) >= 0)
				{
					owner_match = true;
					break;
				}
			}
		}
		if(owner_too && !owner_match)
		{
			owner_match = (parent.options.owner == event.owner ||
				parent_owner.indexOf &&
				parent_owner.indexOf(event.owner) >= 0);
		}
		return owner_match;
	}

	/**
	 * @callback et2_calendar_event~prompt_callback
	 * @param {string} button_id - One of ok, exception, series, single or cancel
	 *	depending on which buttons are on the prompt
	 * @param {Object} event_data - Event information - whatever you passed in to
	 *	the prompt.
	 */
	/**
	 * Recur prompt
	 * If the event is recurring, asks the user if they want to edit the event as
	 * an exception, or change the whole series.  Then the callback is called.
	 *
	 * If callback is not provided, egw.open() will be used to open an edit dialog.
	 *
	 * If you call this on a single (non-recurring) event, the callback will be
	 * executed immediately, with the passed button_id as 'single'.
	 *
	 * @param {Object} event_data - Event information
	 * @param {string} event_data.id - Unique ID for the event, possibly with a
	 *	timestamp
	 * @param {string|Date} event_data.start - Start date/time for the event
	 * @param {number} event_data.recur_type - Recur type, or 0 for a non-recurring event
	 * @param {et2_calendar_event~prompt_callback} [callback] - Callback is
	 *	called with the button (exception, series, single or cancel) and the event
	 *	data.
	 * @param {Object} [extra_data] - Additional data passed to the callback, used
	 *	as extra parameters for default callback
	 *
	 * @augments {et2_calendar_event}
	 */
	public static recur_prompt(event_data, callback?, extra_data?)
	{
		let egw;
		const edit_id = event_data.app_id;
		const edit_date = event_data.start;

		// seems window.opener somehow in certain conditions could be from different origin
		// we try to catch the exception and in this case retrieve the egw object from current window.
		try
		{
			egw = this.egw ? (typeof this.egw == 'function' ? this.egw() : this.egw) : window.opener && typeof window.opener.egw != 'undefined' ? window.opener.egw('calendar'):window.egw('calendar');
		}
		catch(e){
			egw = window.egw('calendar');
		}

		const that = this;

		const extra_params = extra_data && typeof extra_data == 'object' ? extra_data : {};
		extra_params.date = edit_date.toJSON ? edit_date.toJSON() : edit_date;
		if(typeof callback != 'function')
		{
			callback = function(_button_id)
			{
				switch(_button_id)
				{
					case 'exception':
						extra_params.exception = '1';
						egw.open(edit_id, event_data.app||'calendar', 'edit', extra_params);
						break;
					case 'series':
					case 'single':
						egw.open(edit_id, event_data.app||'calendar', 'edit', extra_params);
						break;
					case 'cancel':
					default:
						break;
				}
			};
		}
		if(parseInt(event_data.recur_type))
		{
			const buttons = [
				{text: egw.lang("Edit exception"), id: "exception", class: "ui-priority-primary", "default": true},
				{text: egw.lang("Edit series"), id: "series"},
				{text: egw.lang("Cancel"), id: "cancel"}
			];
			et2_dialog.show_dialog(
				function(button_id) {callback.call(that, button_id, event_data);},
				(!event_data.is_private ? event_data['title'] : egw.lang('private')) + "\n" +
				egw.lang("Do you want to edit this event as an exception or the whole series?"),
				egw.lang("This event is part of a series"), {}, buttons, et2_dialog.QUESTION_MESSAGE
			);
		}
		else
		{
			callback.call(this,'single',event_data);
		}
	}

	/**
	 * Split series prompt
	 *
	 * If the event is recurring and the user adjusts the time or duration, we may need
	 * to split the series, ending the current one and creating a new one with the changes.
	 * This prompts the user if they really want to do that.
	 *
	 * There is no default callback, and nothing happens if you call this on a
	 * single (non-recurring) event
	 *
	 * @param {Object} event_data - Event information
	 * @param {string} event_data.id - Unique ID for the event, possibly with a timestamp
	 * @param {string|Date} instance_date - The date of the edited instance of the event
	 * @param {et2_calendar_event~prompt_callback} callback - Callback is
	 *	called with the button (ok or cancel) and the event data.
	 * @augments {et2_calendar_event}
	 */
	public static series_split_prompt(event_data, instance_date, callback)
	{
		let egw;
		// seems window.opener somehow in certian conditions could be from different origin
		// we try to catch the exception and in this case retrieve the egw object from current window.
		try {
			egw = this.egw ? (typeof this.egw == 'function' ? this.egw() : this.egw) : window.opener && typeof window.opener.egw != 'undefined' ? window.opener.egw('calendar'):window.egw('calendar');
		}
		catch(e){
			egw = window.egw('calendar');
		}

		const that = this;

		if(typeof instance_date == 'string')
		{
			instance_date = new Date(instance_date);
		}

		// Check for modifying a series that started before today
		const tempDate = new Date();
		const today = new Date(tempDate.getFullYear(), tempDate.getMonth(), tempDate.getDate(), tempDate.getHours(), -tempDate.getTimezoneOffset(), tempDate.getSeconds());
		const termination_date = instance_date < today ? egw.lang('today') : date(egw.preference('dateformat'), instance_date);

		if(parseInt(event_data.recur_type))
		{
			et2_dialog.show_dialog(
				function(button_id) {callback.call(that, button_id, event_data);},
				(!event_data.is_private ? event_data['title'] : egw.lang('private')) + "\n" +
				egw.lang("Do you really want to change the start of this series? If you do, the original series will be terminated as of %1 and a new series for the future reflecting your changes will be created.", termination_date),
				egw.lang("This event is part of a series"), {}, et2_dialog.BUTTONS_OK_CANCEL , et2_dialog.WARNING_MESSAGE
			);
		}
	}

	public static drag_helper (event,ui)
	{
		ui.helper.width(ui.width());
	}

	/**
	* splits the combined status, quantity and role
	*
	* @param {string} status - combined value, O: status letter: U, T, A, R
	* @param {int} [quantity] - quantity
	* @param {string} [role]
	* @return string status U, T, A or R, same as $status parameter on return
	*/
	public static split_status (status,quantity?,role?)
	{
		quantity = 1;
		role = 'REQ-PARTICIPANT';
		//error_log(__METHOD__.__LINE__.array2string($status));
		let matches = null;
		if (typeof status === 'string' && status.length > 1)
		{
			matches = status.match(/^.([0-9]*)(.*)$/gi);
		}
		if(matches)
		{
			if (parseInt(matches[1]) > 0) quantity = parseInt(matches[1]);
			if (matches[2]) role = matches[2];
			status = status[0];
		}
		else if (status === true)
		{
			status = 'U';
		}
		return status;
	}

	/**
	 * The egw_action system requires an egwActionObjectInterface Interface implementation
	 * to tie actions to DOM nodes.  I'm not sure if we need this.
	 *
	 * The class extension is different than the widgets
	 *
	 * @param {et2_DOMWidget} widget
	 * @param {Object} node
	 *
	 */
	public static et2_event_action_object_impl(widget, node)
	{
		const aoi = new et2_action_object_impl(widget, node).getAOI();

		// _outerCall may be used to determine, whether the state change has been
	// evoked from the outside and the stateChangeCallback has to be called
	// or not.
		aoi.doSetState = function(_state, _outerCall) {
		};

		return aoi;
	}
}
et2_register_widget(et2_calendar_event, ["calendar-event"]);