import {html, LitElement, nothing, PropertyValueMap, PropertyValues, TemplateResult} from "lit";
import {customElement} from "lit/decorators/custom-element.js";
import {property} from "lit/decorators/property.js";
import {state} from "lit/decorators/state.js";
import {classMap} from "lit/directives/class-map.js";
import styles from "./EgwFrameworkApp.styles";
import {SlDrawer, SlSplitPanel, SlTooltip} from "@shoelace-style/shoelace";
import {HasSlotController} from "../../api/js/etemplate/Et2Widget/slot";
import type {EgwFramework, FeatureList} from "./EgwFramework";
import {etemplate2} from "../../api/js/etemplate/etemplate2";
import {et2_IPrint} from "../../api/js/etemplate/et2_core_interfaces";
import {repeat} from "lit/directives/repeat.js";
import {until} from "lit/directives/until.js";
import {Favorite} from "../../api/js/etemplate/Et2Favorites/Favorite";
import type {Et2Template} from "../../api/js/etemplate/Et2Template/Et2Template";
import {et2_nextmatch} from "../../api/js/etemplate/et2_extension_nextmatch";
import type {Et2Filterbox} from "../../api/js/etemplate/Et2Filterbox/Et2Filterbox";
import {waitForEvent} from "../../api/js/etemplate/Et2Widget/event";
import {egw_getAppObjectManager} from "../../api/js/egw_action/egw_action";

/**
 * @summary Application component inside EgwFramework
 *
 * Contain an EGroupware application inside the main framework.  It consists of left, main and right areas.  Each area
 * has a header, content and footer.  Side content areas are not shown when there is no content.
 *
 * @dependency sl-split-panel
 *
 * @slot - Main application content.  Other slots are normally hidden if they have no content
 * @slot main-header - Top of app.  Contains logo, application toolbar, search box etc.
 * @slot header-actions - Top right - filter, refresh, print & menu live here
 * @slot filter - Custom filter panel content, leave empty for auto-generated filters
 * @slot header - Top of main content
 * @slot footer - Very bottom of the main content.
 * @slot left - Optional content to the left.  Use for application navigation.
 * @slot left-header - Top of left side
 * @slot left-top - Between left-header and Favourites
 * @slot left-footer - bottom of left side
 * @slot right - Optional content to the right.  Use for application context details.
 * @slot right-header - Top of right side
 * @slot right-footer - bottom of right side
 *
 * @csspart app-header - Top bar of application, contains name, header.
 * @csspart name - Top left, holds the application name.
 * @csspart header - Top main application header, optional application toolbar goes here.
 * @csspart app-menu - Drop down application menu, top right
 * @csspart content-header - Top of center, optional.
 * @csspart main - Main application content.
 * @csspart left - Left optional content.
 * @csspart right - Right optional content.
 * @csspart footer - Very bottom of the main content.
 * @csspart spinner - Loading spinner
 *
 * @cssproperty [--application-color=--primary-background-color] - Color to use for this application
 * @cssproperty [--application-header-text-color=white] - Text color in the application header
 * @cssproperty [--left-min=0] - Minimum width of the left content
 * @cssproperty [--left-max=20%] - Maximum width of the left content
 * @cssproperty [--right-min=0] - Minimum width of the right content
 * @cssproperty [--right-max=50%] - Maximum width of the right content
 */
@customElement('egw-app')
//@ts-ignore
export class EgwFrameworkApp extends LitElement
{
	static get styles()
	{
		return [
			styles
		];
	}

	@property({reflect: true})
	name = "Application name";

	@property()
	title : string = "";

	@property()
	url = "";

	private _allow= "";
	/** allow property of iframe, if an iframe is used */
	@property()
	set allow(allow : string)
	{
		if (this.iframe && this._allow != allow)
		{
			// setting the allow attribute after creating the node seems to NOT grant / allow the requested features :(
			// therefore we replace the whole iframe with a new one with the features set by createElement
			this.iframe.replaceWith(this._createIframeNodes(this.url, allow));
		}
		this._allow = allow;
	}
	get allow() : string
	{
		return this._allow;
	}

	@property({attribute: 'open-once'})
	openOnce = '';

	@property()
	features : FeatureList = {};

	@property({reflect: true})
	rowCount : string = "";

	/**
	 * Display application in a loading state.
	 * @type {boolean}
	 */
	@property({type: Boolean, reflect: true})
	loading = false;

	/**
	 * A function that gets the application's nextmatch so we can generate the filter drawer.
	 * If the application has multiple nextmatches, it can override this to return the "current" nextmatch
	 *
	 * @return {et2_nextmatch}
	 */
	@property({type: Function})
	getNextmatch : () => et2_nextmatch = () : et2_nextmatch =>
	{
		// Look for a nextmatch by finding the DOM node by CSS class
		let nm = null;
		const nm_div = this.querySelector(".et2_nextmatch");
		if(nm_div)
		{
			// Found DOM node, get the widget
			const template = (<Et2Template>nm_div.closest("et2-template"));
			const widget_id = nm_div.id.replace(template?.getInstanceManager().uniqueId + "_", "");
			nm = template.getWidgetById(widget_id);
		}
		return nm;
	}

	/**
	 * A function that provides the icon & tooltip for the filter button shown in the application header.
	 * Applications can provide their own function to change the icon based on their own status
	 *
	 * @return {icon: string, tooltip: string}
	 */
	@property({type: Function})
	getFilterInfo : (filterValues : {
		[id : string] : string | { value : any }
	}, fwApp : EgwFrameworkApp) => FilterInfo =
		(filterValues, fwApp) : FilterInfo =>
	{
		return this.filterInfo(filterValues);
	}

	@state()
	leftCollapsed = false;

	@state()
	rightCollapsed = false;

	/**
	 * Some child has a selection, so we want to enable the context menu button.
	 * This is for mobile only.
	 *
	 * @type {boolean}
	 */
	@state() hasContextMenu = false;

	get leftSplitter() { return <SlSplitPanel>this.shadowRoot?.querySelector(".egw_fw_app__outerSplit");}

	get rightSplitter() { return <SlSplitPanel>this.shadowRoot?.querySelector(".egw_fw_app__innerSplit");}

	get iframe() { return <HTMLIFrameElement>this.querySelector(":scope > iframe:not([name='egw_iframe_autocomplete_helper'])");}

	get filters() { return <Et2Filterbox>this.querySelector("et2-filterbox:not([hidden],[disabled])");}

	get filtersDrawer() { return <SlDrawer>this.shadowRoot?.querySelector(".egw_fw_app__filter_drawer");}


	protected readonly hasSlotController = new HasSlotController(<LitElement><unknown>this,
		'left', 'left-header', 'left-footer',
		'right', 'right-header', 'right-footer',
	);

	// Left is in pixels
	private leftPanelInfo : PanelInfo = {
		side: "left",
		preference: "app_left_width",
		defaultWidth: 10,
		hiddenWidth: 0,
		preferenceWidth: 10
	};
	// Right is in percentage
	private rightPanelInfo : PanelInfo = {
		side: "right",
		preference: "app_right_width",
		defaultWidth: 50,
		hiddenWidth: 100,
		preferenceWidth: 50
	};
	private resizeTimeout : number;

	protected loadingPromise = Promise.resolve();

	/** The application's content must be in an iframe instead of handled normally */
	protected useIframe = false;
	protected _sideboxData : any;
	/** We've loaded something other than our set url via load(...), on refresh go back */
	private _offUrl : boolean = false;

	constructor()
	{
		super();
		this.handleTabHide = this.handleTabHide.bind(this);
		this.handleTabShow = this.handleTabShow.bind(this);

		this.handleSearchResults = this.handleSearchResults.bind(this);
		this.handleShow = this.handleShow.bind(this);
	}
	connectedCallback()
	{
		super.connectedCallback();

		// Left panel starts hidden if we're too small
		if(this.hasAttribute("active") && this.clientWidth < 600)
		{
			this.leftCollapsed = true;
		}
		this.addEventListener("load", this.handleEtemplateLoad);
		this.addEventListener("clear", this.handleEtemplateClear);

		// Listen to nextmatches
		this.addEventListener("et2-search-result", this.handleSearchResults);
		this.addEventListener("et2-show", this.handleShow);

		// Work around sl-split-panel resizing to 0 when app is hidden
		this.framework.addEventListener("sl-tab-hide", this.handleTabHide);
		this.framework.addEventListener("sl-tab-show", this.handleTabShow);
	}

	disconnectedCallback()
	{
		super.disconnectedCallback();
		this.removeEventListener("load", this.handleEtemplateLoad);
		this.removeEventListener("clear", this.handleEtemplateClear);
		this.removeEventListener("et2-search-result", this.handleSearchResults);
		this.removeEventListener("et2-show", this.handleShow);
		this.framework?.removeEventListener("sl-tab-hide", this.handleTabHide);
		this.framework?.removeEventListener("sl-tab-show", this.handleTabShow);
		(this.hasSlotController as any) = null;

		try
		{
			this.childNodes.forEach((childNode : HTMLElement) =>
			{
				const et = etemplate2.getById(childNode.id);
				if(et !== null)
				{
					et.clear();
					// Clean up DOM nodes that are outside the etemplate2
					const domContainer = et.DOMContainer;
					domContainer.parentNode?.querySelector("[name='egw_iframe_autocomplete_helper']")?.remove();
					domContainer.remove();
					// @ts-ignore et._DOMContainer is private
					et._DOMContainer = null;
				}
			});
			// Destroy application js
			if(window.app[this.name] && window.app[this.name].destroy)
			{
				window.app[this.name].destroy();
				delete window.app[this.name];	// really delete it, so new object get constructed and registered for push
			}
		}
		catch(e)
		{
			this.egw.debug("error", "Error closing application", e);
		}
	}

	willUpdate(changedProperties : PropertyValueMap<this>)
	{
		if(changedProperties.has("name"))
		{
			// Get size preferences now that this.name is set
			// @ts-ignore preference() takes _callback = true
			this.egw.preference(this.leftPanelInfo.preference, this.name, true).then((value) =>
			{
				this.leftPanelInfo.preferenceWidth = typeof value !== "undefined" ? value : this.leftPanelInfo.defaultWidth;
			});

			// @ts-ignore preference() takes _callback = true
			this.egw.preference(this.rightPanelInfo.preference, this.name, true).then((value) =>
			{
				this.rightPanelInfo.preferenceWidth = typeof value !== "undefined" ? value : this.rightPanelInfo.defaultWidth;
			});
		}
	}
	firstUpdated()
	{
		this.load(this.url, this.allow);
		if(this.openOnce && this.openOnce != this.url)
		{
			this.framework.openPopup(this.openOnce, false, false, "", this.name, true, false, window);
		}
		this.openOnce = "";
	}

	protected updated(changedProperties : PropertyValues)
	{
		super.updated(changedProperties);

		// rowCount changed, show as tooltip for a few seconds
		if(changedProperties.has("rowCount"))
		{
			this.updateComplete.then(() =>
			{
				const tooltip = <SlTooltip>this.shadowRoot.querySelector('.egw_fw_app__filter_info_tooltip');
				if(tooltip)
				{
					tooltip.open = true;
					setTimeout(() =>
					{
						const tooltip = <SlTooltip>this.shadowRoot.querySelector('.egw_fw_app__filter_info_tooltip');
						if(tooltip)
						{
							tooltip.open = false;
						}
					}, 3000);
				}
			});
		}
	}

	protected async getUpdateComplete() : Promise<boolean>
	{
		const result = await super.getUpdateComplete();
		await Promise.allSettled([
			this.loadingPromise,
			customElements.whenDefined("sl-split-panel"),
			this.leftSplitter?.updateComplete,
			this.rightSplitter?.updateComplete
		]);
		return result
	}

	public load(url)
	{
		if(this.egw.debug_level() >= 4)
		{
			window.performance.mark("mark_egw_app_start_load_" + this.name);
		}

		// Give application the first chance to handle the link, it might avoid a reload
		if(window.app[this.name]?.linkHandler && this.egw.window.app[this.name].linkHandler(url))
		{
			// app.ts linkHandler handled it.
			if(this.egw.debug_level() >= 4)
			{
				window.performance.mark("mark_egw_app_end_load_" + this.name);
			}
			return;
		}

		// Loaded something else, refresh will go back instead of refreshing nextmatch
		this._offUrl = (this.url != url);

		// Clear everything
		Array.from(this.children).forEach(n =>
		{
			etemplate2.getById(n.id)?.clear();
			n.remove();
		});
		if(!url)
		{
			return;
		}

		let targetUrl = "";
		this.useIframe = true;
		let matches = url.match(/\/index.php\?menuaction=([A-Za-z0-9_\.]*.*[&?]ajax=[^&]+.*)/);
		if(matches)
		{
			// Matches[1] contains the menuaction which should be executed - replace
			// the given url with the following line. This will be evaluated by the
			// jdots_framework ajax_exec function which will be called by the code
			// below as we set useIframe to false.
			targetUrl = "index.php?menuaction=" + matches[1];
			this.useIframe = false;
		}

		this.loading = true;
		if(this.egw.debug_level() >= 4)
		{
			window.performance.mark("mark_egw_app_fetch_start_" + this.name);
		}
		if(!this.useIframe)
		{
			this.loadingPromise = this.egw.request(
				this.framework.getMenuaction('ajax_exec', targetUrl, this.name),
				[targetUrl]
			).then((data : string | string[] | { DOMNodeID? : string } | { DOMNodeID? : string }[]) =>
			{
				if(this.egw.debug_level() >= 4)
				{
					window.performance.mark("mark_egw_app_fetch_end_" + this.name);
					window.performance.measure("egw_app_fetch_" + this.name, "mark_egw_app_fetch_start_" + this.name, "mark_egw_app_fetch_end_" + this.name);
					window.performance.mark("mark_egw_app_contents_start_" + this.name);
				}
				if(!data)
				{
					return;
				}

				// Load request returns HTML.  Shove it in.
				this.innerHTML = (<string[]>data).join("");

				// Might have just slotted aside content, hasSlotController will requestUpdate()
				// but we need to do it anyway for translation
				this.requestUpdate();

				// Wait for children to load
				return this.waitForLoad(Array.from(this.querySelectorAll("[id]")));
			})
		}
		else
		{
			this.loadingPromise = new Promise<void>((resolve, reject) =>
			{
				this.append(this._createIframeNodes(url));
				if(this.egw.debug_level() >= 4)
				{
					window.performance.mark("mark_egw_app_fetch_end_" + this.name);
					window.performance.measure("egw_app_fetch_" + this.name, "mark_egw_app_fetch_start_" + this.name, "mark_egw_app_fetch_end_" + this.name);
					window.performance.mark("mark_egw_app_contents_start_" + this.name);
				}
				this.hideLeft();
				this.hideRight();

				// Might have just changed useIFrame, need to update to show that
				this.requestUpdate();
				resolve();

				// Wait for children to load
				return this.waitForLoad(Array.from(this.querySelectorAll("iframe")));
			});
		}
		this.loadingPromise
			.then(() =>
			{
				if(this.egw.debug_level() >= 4)
				{
					window.performance.mark("mark_egw_app_contents_end_" + this.name);
				}
				// Nextmatches that were hidden need a resize (admin, other apps seem to be fine)
				this.querySelectorAll(":scope > [id]").forEach((node) =>
				{
					etemplate2.getById(node.id)?.resize(undefined);
				});
			})
			.catch((e) =>
			{
				debugger;
				console.log(e)
			})
			.finally(() =>
			{
				this.loading = false;
				if(this.egw.debug_level() >= 4)
				{
					window.performance.mark("mark_egw_app_end_load_" + this.name);
					window.performance.measure("egw_app_contents_" + this.name, "mark_egw_app_contents_start_" + this.name, "mark_egw_app_contents_end_" + this.name);
					window.performance.measure("egw_app_load_" + this.name, "mark_egw_app_start_load_" + this.name, "mark_egw_app_end_load_" + this.name);
				}
			});
		return this.loadingPromise
	}

	// Wait for the nodes to fire a "load" event, when all are done then we're done loading
	protected waitForLoad(nodes : HTMLElement[]) : Promise<void>
	{
		let timeout = null;
		let iframePoll = [];
		const loadTimeoutPromise = new Promise<void>((resolve) =>
		{
			timeout = setTimeout(() =>
			{
				console.warn(this.name + ' loading timeout', this);
				resolve(); // Don't reject — just proceed
			}, 10000);
		});
		const loadPromises = nodes.map((node) =>
		{
			if(node.localName == "iframe")
			{
				// iframes don't fire "load" by spec, so poll for contents
				return new Promise<void>((resolve) =>
				{
					const interval = setInterval(() =>
					{
						if((<HTMLIFrameElement>node).contentDocument?.body?.innerHTML != "")
						{
							clearInterval(interval);
							resolve();
						}
					}, 500);
					iframePoll.push(interval);
				});
			}
			else
			{
				return waitForEvent(node, "load")
			}
		});

		return Promise.race([
			Promise.any(loadPromises)
				.then(() => {/* yay ... */ })
				.finally(() =>
				{
					clearTimeout(timeout);
					iframePoll.forEach((interval) => clearInterval(interval));
				}),
			loadTimeoutPromise
		]) as Promise<void>;
	}

	public getMenuaction(_fun, _ajax_exec_url, appName = "")
	{
		return this.framework.getMenuaction(_fun, _ajax_exec_url, appName || this.name);
	}

	public setSidebox(sideboxData, hash?)
	{
		this._sideboxData = sideboxData;

		if(this.features?.favorites || this._sideboxData?.some(s => s.title == "Favorites" || s.title == this.egw.lang("favorites")))
		{
			this.features.favorites = true;
			// This might be a little late, but close enough for rendering
			Favorite.load(this.egw, this.name).then((favorites) =>
			{
				this.requestUpdate();
			});
		}

		this.requestUpdate();
	}

	public showLeft(size = null)
	{
		return this.showSide("left", size);
	}

	public hideLeft()
	{
		return this.hideSide("left");
	}

	public showRight(size = null)
	{
		return this.showSide("right", size);
	}

	public hideRight()
	{
		return this.hideSide("right");
	}

	/**
	 * Refresh given application by refreshing etemplates, or reloading URL
	 * @param {string} _msg message (already translated) to show, eg. 'Entry deleted'
	 * @param {string|number|undefined} _id id of entry to refresh
	 * @param {string|undefined} _type either 'edit', 'delete', 'add' or undefined
	 */
	public refresh(_msg, _id, _type)
	{
		this.loading = true;
		if(typeof _msg !== "string")
		{
			_msg = "";
		}
		if(this._offUrl)
		{
			return this.load(this.url);
		}

		// Refresh all child etemplates
		const etemplates = {};
		let refresh_done = false;
		this.querySelectorAll(":scope > [id]").forEach((t) =>
		{
			refresh_done = etemplate2.getById(t.id)?.refresh(_msg, this.appName, _id, _type) || refresh_done;
		});

		// if not trigger a full app refresh
		if(!refresh_done)
		{
			this.load(this.url + (_msg ? '&msg=' + encodeURIComponent(_msg) : ''));
		}
		else
		{
			this.loading = false;
		}
	}

	private _refresh()
	{
		// @ts-ignore
		return this.egw.refresh("", this.name, null, null, this.name);
	}

	public async print()
	{

		let template;
		let deferred = [];
		let et2_list = [];
		const appWindow = this.framework.egw.window;

		// @ts-ignore that etemplate2 doesn't exist
		if((template = appWindow.etemplate2.getById(this.id)) && this == template.DOMContainer)
		{
			deferred = deferred.concat(template.print());
			et2_list.push(template);
		}
		else
		{
			// et2 inside, let its widgets prepare
			this.querySelectorAll(":scope > *").forEach((domNode : HTMLElement) =>
			{
				// @ts-ignore etemplate2 doesn't exist
				let et2 = appWindow.etemplate2.getById(domNode.id);
				if(et2 && (domNode.offsetWidth > 0 || domNode.offsetHeight > 0 || domNode.getClientRects().length > 0))
				{
					deferred = deferred.concat(et2.print());
					et2_list.push(et2);
				}
			});
		}

		if(et2_list.length)
		{
			// Try to clean up after - not guaranteed
			let afterPrint = () =>
			{
				this.egw.loading_prompt(this.name, true, this.egw.lang('please wait...'), this, egwIsMobile() ? 'horizontal' : 'spinner');

				// Give framework a chance to deal, then reset the etemplates
				appWindow.setTimeout(() =>
				{
					for(let i = 0; i < et2_list.length; i++)
					{
						et2_list[i].widgetContainer.iterateOver(function(_widget)
						{
							_widget.afterPrint();
						}, et2_list[i], et2_IPrint);
					}
					this.egw.loading_prompt(this.name, false);
				}, 100);
				appWindow.onafterprint = null;
			};
			/* Not sure what this did, it triggers while preview is still up
			if(appWindow.matchMedia)
			{
				var mediaQueryList = appWindow.matchMedia('print');
				var listener = function(mql)
				{
					if(!mql.matches)
					{
						mediaQueryList.removeListener(listener);
						afterPrint();
					}
				};
				mediaQueryList.addListener(listener);
			}

			 */

			appWindow.addEventListener("afterprint", afterPrint, {once: true});

			// Wait for everything to be ready
			return Promise.all(deferred).catch((e) =>
			{
				afterPrint();
				if(typeof e == "undefined")
				{
					throw "rejected";
				}
			});
		}
	}

	protected showSide(side : "left" | "right", size = null)
	{
		const attribute = `${side}Collapsed`;
		this[attribute] = false;
		this[`${side}PanelInfo`].preferenceWidth = size ?? (this[`${side}PanelInfo`].preferenceWidth || this[`${side}PanelInfo`].defaultWidth);
		return this.updateComplete.then(() =>
		{
			this.dispatchEvent(new CustomEvent("show",
				{bubbles: true, composed: true, detail: {name: this.name, side: side}}));

			// Tell etemplates to resize because they won't do it themselves
			this.querySelectorAll(":scope > [id]").forEach((node) =>
			{
				const etemplate = etemplate2.getById(node.id);
				etemplate && etemplate.resize(undefined);
			});
		});
	}

	protected hideSide(side : "left" | "right")
	{
		const attribute = `${side}Collapsed`;
		const oldValue = this[attribute];
		this[attribute] = true;
		this[`${side}Splitter`].position = this[`${side}PanelInfo`].hiddenWidth;
		this.requestUpdate(attribute, oldValue);
		return this.updateComplete.then(() =>
		{
			this.dispatchEvent(new CustomEvent("hide",
				{bubbles: true, composed: true, detail: {name: this.name, side: side}}));

			// Tell etemplates to resize, because they won't do it themselves
			this.querySelectorAll(":scope > [id]").forEach((node) =>
			{
				const etemplate = etemplate2.getById(node.id);
				etemplate && etemplate.resize(undefined);
			});
		});
	}

	get egw()
	{
		return window.egw(this.name) ?? (<EgwFramework>this.parentElement).egw ?? window.egw;
	}

	get framework() : EgwFramework
	{
		return this.closest("egw-framework");
	}

	get appName() : string
	{
		return this.name;
	}

	get nextmatch() : et2_nextmatch
	{
		return this.getNextmatch()
	}

	/**
	 * The app is showing the normal application view (and does not need to reset before working properly)
	 *
	 * @return {boolean}
	 */
	get isUsingAppUrl() : boolean
	{
		return !this._offUrl;
	}

	/**
	 * Default implementation for getFilterInfo
	 * Exposed so apps can use this as a base for their own getFilterInfo
	 */
	public filterInfo(filterValues) : FilterInfo
	{
		const info = {
			icon: "filter-circle",
			tooltip: this.egw.lang("Filters") + ":  " +
				this.rowCount + " " + (this.egw.link_get_registry(this.name, "entries") || this.egw.lang("entries"))
		};

		// Don't consider sort as a filter
		delete filterValues.sort;
		
		// If there are no filters set, show filter-circle.  Show filter-circle-fill if there are filters set.
		const emptyFilter = (v) => typeof v == "object" ? Object.values(v).filter(emptyFilter).length : v;
		if(Object.values(filterValues).filter(emptyFilter).length !== 0)
		{
			info.icon = "filter-circle-fill";
		}
		return info;
	}

	private hasSideContent(side : "left" | "right")
	{
		let hasContent = this.hasSlotController?.test(`${side}-header`) ||
			this.hasSlotController?.test(side) || this.hasSlotController?.test(`${side}-footer`);

		if(side == "left" && !hasContent)
		{
			// Left side has an additional slot above the favourites
			hasContent = hasContent || this.hasSlotController?.test("left-top") ||
				// Favourites work through egw_app class, so if it's not there, favourites won't work
				this.features?.favorites && typeof window.app[this.name] !== "undefined";
		}
		return hasContent;
	}

	/**
	 * Handle a click on the context menu button, which should only be shown for mobile
	 *
	 * @param event
	 * @private
	 */
	private handleContextButton(event)
	{
		event.stopPropagation();

		// Find the active thing and fire an event on it
		let context = null;
		let om = null;
		if(!this.leftCollapsed)
		{
			// If the left is open with a tree, use that
			const tree = <HTMLInputElement>this.querySelector("et2-tree[slot*=left]") ?? this.querySelector("[slot*=left] et2-tree");
			if(tree && tree.value)
			{
				context = tree.shadowRoot.querySelector("[selected]");
				om = tree["widget_object"];
			}
		}
		om = om ?? egw_getAppObjectManager(false, this.name);
		if(om && !context)
		{
			context = om.getFocusedObject()?.iface.getDOMNode()
		}
		if(context)
		{
			context.dispatchEvent(new CustomEvent("tapandhold", {
				// type isn't an option, but needed for action system
				//type: 'tapandhold',
				bubbles: true,
				composed: true,
				cancelable: true
			}));
		}
		//this.getNextmatch().getDOMNode().getElementsByClassName('selected')[0].dispatchEvent(new CustomEvent("tapandhold", {type: 'tapandhold'}));
	}

	/**
	 * An etemplate has loaded inside
	 * Move anything top-level that has a slot
	 */
	protected handleEtemplateLoad(event)
	{
		const etemplate = etemplate2.getById(event.target.id);
		if(!etemplate || !event.composedPath().includes(this))
		{
			return;
		}

		// Move templates with slots (along with DOMContainer if only one template there to keep it together)
		const slottedTemplates = etemplate.DOMContainer.querySelectorAll(":scope > [slot]");
		if(slottedTemplates.length == 1 && etemplate.DOMContainer.childElementCount == 1)
		{
			etemplate.DOMContainer.slot = slottedTemplates[0].slot;
		}
		else
		{
			slottedTemplates.forEach(node => {this.appendChild(node);});
		}

		// Get top level slotted components
		const slottedWidgets = etemplate.widgetContainer?.querySelectorAll(":scope > [slot]") ?? []

		// Request update, since slotchanged events are only fired when the attribute changes and they're already set
		if(slottedTemplates.length > 0 || slottedWidgets.length > 0 || this.nextmatch)
		{
			this.requestUpdate();
		}

		// Enable the context menu button if there's a nextmatch or tree in the left slot
		const tree = <HTMLInputElement>this.querySelector("et2-tree[slot*=left]") ?? this.querySelector("[slot*=left] et2-tree");
		this.hasContextMenu = Boolean(typeof tree !== "undefined" || this.nextmatch).valueOf();
	}

	/**
	 * An etemplate has been cleared
	 * Clear any references & clean up
	 */
	protected handleEtemplateClear(event)
	{
		if(this.nextmatch && this.nextmatch.getInstanceManager().DOMContainer === event.target)
		{
			this.filters.nextmatch = null;
			this.requestUpdate("nextmatch");
		}
	}

	protected handleFilterButtonClick(event)
	{
		const filter = event.target.getRootNode().host.filtersDrawer;
		filter.open = !filter.open;
	}

	/**
	 * Listen for show events from children
	 *
	 * - Nextmatch: If a nextmatch claims to be shown, we get its row count
	 *
	 * @param event
	 * @protected
	 */
	protected handleShow(event)
	{
		if(event.detail instanceof et2_nextmatch)
		{
			this.rowCount = event.detail.controller.getTotalCount();
		}
	}

	/**
	 * User adjusted side slider, update preference
	 *
	 * @param event
	 * @protected
	 */
	protected async handleSlide(event)
	{
		event.stopPropagation();

		// Skip if there's no panelInfo - event is from the wrong place
		// Skip if loading, or not active to avoid keeping changes while user is not interacting
		if(typeof event.target?.dataset.panel == "undefined" || this.loading || !this.hasAttribute("active"))
		{
			return;
		}
		const split = event.target;
		let panelInfo = this[split.dataset.panel];

		// Automatically collapse if there's no content
		if(split.classList.contains("no-content"))
		{
			this[`${panelInfo.side}Collapsed`] = true;
		}
		if(this[`${panelInfo.side}Collapsed`] && !split.isCollapsed)
		{
			// It's collapsed, it doesn't move
			split.isCollapsed = true;
			return;
		}
		let newPosition = split.position;
		await split.updateComplete;

		// Update collapsed
		const oldCollapsed = this[`${panelInfo.side}Collapsed`];
		this[`${panelInfo.side}Collapsed`] = split.isCollapsed;
		if(oldCollapsed != this[`${panelInfo.side}Collapsed`])
		{
			this.dispatchEvent(new CustomEvent(oldCollapsed ? "show" : "hide",
				{bubbles: true, composed: true, detail: {name: this.name, side: panelInfo.side}}
			));
		}

		let preferenceName = panelInfo.preference;

		// Send it out with details, in case anyone cares
		this.dispatchEvent(new CustomEvent("sl-reposition", {
				detail: {
					name: this.name,
					side: panelInfo.side,
					preference: preferenceName,
					width: newPosition,
				},
				bubbles: true,
				composed: true,
			}
		));

		// Delay preference update & etemplate resize because they're expensive
		if(!this[`${panelInfo.side}Collapsed`] && newPosition != panelInfo.preferenceWidth)
		{
			if(panelInfo.resizeTimeout)
			{
				window.clearTimeout(panelInfo.resizeTimeout);
			}
			panelInfo.resizeTimeout = window.setTimeout(() =>
			{
				panelInfo.resizeTimeout = null;
				console.log(`Panel resize: ${this.name} ${panelInfo.side} ${panelInfo.preferenceWidth} -> ${newPosition}`);
				panelInfo.preferenceWidth = newPosition;
				this.egw.set_preference(this.name, preferenceName, newPosition);

				// Tell etemplates to resize
				this.querySelectorAll(":scope > [id]").forEach(e =>
				{
					if(etemplate2.getById(e.id))
					{
						etemplate2.getById(e.id).resize(new Event("resize"));
					}
				});
			}, 500);
		}
	}

	/**
	 * Handle search result event from nextmatch
	 *
	 * @param event
	 * @protected
	 */
	protected handleSearchResults(event)
	{
		if(event.detail?.nextmatch == this.nextmatch && !event.defaultPrevented)
		{
			this.rowCount = event.detail?.total ?? "";
		}
	}

	protected async handleSideboxMenuClick(event)
	{
		return this.egw.open_link(event.target.dataset.link, event.target.dataset.target, event.target.dataset.popup, event.target.dataset.app);
	}

	/**
	 * Framework's hidden this tab
	 *
	 * @param event
	 * @return {Promise<void>}
	 * @protected
	 */
	protected async handleTabHide(event)
	{
		// Only interested in this tab
		if(event.detail.name !== this.name)
		{
			return;
		}

		// Stop splitter from resizing while app is not active
		this.leftSplitter != null && (this.leftSplitter.disabled = true);
		this.rightSplitter != null && (this.rightSplitter.disabled = true);
	}

	/**
	 * Framework has shown a tab
	 *
	 * @param event
	 * @protected
	 */
	protected async handleTabShow(event)
	{
		// Only interested in this tab
		if(event.detail.name !== this.id)
		{
			return;
		}

		// Fix splitter if it has moved while hidden
		const resetPanel = async(splitter : SlSplitPanel) =>
		{
			if(!splitter)
			{
				return;
			}
			splitter.detectSize();
			splitter.disabled = false;
		}
		await this.updateComplete;
		[this.rightSplitter, this.leftSplitter].forEach(splitter => void resetPanel(splitter));

		// Say that panels have changed
		this.dispatchEvent(new CustomEvent(this.leftCollapsed ? "hide" : "show",
			{bubbles: true, composed: true, detail: {name: this.name, side: this.leftPanelInfo.side}}
		));
		this.dispatchEvent(new CustomEvent(this.rightCollapsed ? "hide" : "show",
			{bubbles: true, composed: true, detail: {name: this.name, side: this.rightPanelInfo.side}}
		));
	}

	protected handleAppMenuClick(event)
	{
		// @ts-ignore
		return egw_link_handler(`/egroupware/index.php?menuaction=admin.admin_ui.index&load=admin.uiconfig.index&appname=${this.name}&ajax=true`, 'admin');
	}

	/**
	 * Displayed for the time between when the application is added and when the server responds with content
	 *
	 * @returns {TemplateResult<1>}
	 * @protected
	 */
	protected _loadingTemplate(slot = null)
	{
		// Don't show loader for iframe, it will not resolve
		if(this.useIframe)
		{
			return nothing;
		}

		return html`
            <div class="egw_fw_app__loading" slot=${slot || nothing}>
                <sl-spinner part="spinner"></sl-spinner>
            </div>`;
	}

	/**
	 * If we have to use an iframe, this is where it is made
	 *
	 * @protected
	 */
	protected _createIframeNodes(url? : string, allow? : string)
	{
		if(!this.useIframe)
		{
			return null;
		}
		return Object.assign(document.createElement("iframe"), {src: url}, allow || this.allow ? {allow: allow ?? this.allow} : {});
	}

	protected _asideTemplate(parentSlot, side : "left" | "right", label?)
	{
		const asideClassMap = classMap({
			"egw_fw_app__aside": true,
			"egw_fw_app__left": side == "left",
			"egw_fw_app__right": side == "right",
			"egw_fw_app__aside-collapsed": side == "left" ? this.leftCollapsed : this.rightCollapsed,
		});
		return html`
            <aside slot="${parentSlot}" part="${side}" class=${asideClassMap} aria-label="${label}">
                <div class="egw_fw_app__aside_header header">
                    <slot name="${side}-header"></slot>
                </div>
                <div class="egw_fw_app__aside_content content">
                    ${side == "left" ? this._leftMenuTemplate() : nothing}
                    <slot name="${side}"></slot>
                </div>
                <div class="egw_fw_app__aside_footer footer">
                    <slot name="${side}-footer"></slot>
                </div>
            </aside>`;
	}

	/**
	 * Left sidebox automatic content
	 *
	 * @protected
	 */
	protected _leftMenuTemplate()
	{
		// Put favorites in left sidebox if any are set
		let favorites : symbol | TemplateResult = nothing;
		if(this.features?.favorites)
		{
			favorites = html`${until(Favorite.load(this.egw, this.name).then((favorites) =>
			{
				// Add favorite menu to sidebox
				const favSidebox = this._sideboxData?.find(s => s.title.toLowerCase() == "favorites" || s.title == this.egw.lang("favorites"));
				return html`
                    <et2-details
                            exportparts="base:details-base, header:details-header, summary:details-summary, content:details-content"
                            class="favorites sidebox" slot="left"
                                ?open=${favSidebox?.opened}
                                summary=${this.egw.lang("Favorites")}
                                @sl-show=${() => {this.egw.set_preference(this.name, 'jdots_sidebox_' + favSidebox.menu_name, true);}}
                                @sl-hide=${() => {this.egw.set_preference(this.name, 'jdots_sidebox_' + favSidebox.menu_name, false);}}
                    >
                        <et2-favorites-menu application=${this.name} sortable></et2-favorites-menu>
                    </et2-details>
				`;
			}), nothing)}`;
		}
		return html`
            <slot name="left-top"></slot>
            ${favorites}
		`
	}

	/**
	 * Top right header, contains application action buttons (reload, print, config)
	 * @returns {TemplateResult<1>}
	 * @protected
	 */
	protected _rightHeaderTemplate()
	{
		return html`
            ${this._filterButtonTemplate()}
            <et2-button-icon nosubmit name="arrow-clockwise"
                             label=${this.egw.lang("Reload %1", this.egw.lang(this.name))}
                             statustext=${this.egw.lang("Reload %1", this.egw.lang(this.name))}
                             @click=${this._refresh}
            ></et2-button-icon>
            <et2-button-icon nosubmit name="printer" class="egw_fw_app--no_mobile"
                             label=${this.egw.lang("Print")}
                             statustext=${this.egw.lang("Print")}
                             @click=${this.framework.print}
            ></et2-button-icon>
            <sl-dropdown class="egw_fw_app__menu" title="${this.egw.lang("Menu")}">
                <div slot="trigger">
                    <et2-button-icon name="chevron-double-down"
                                     label="${this.egw.lang("Application menu")}"
                    ></sl-icon-button>
                </div>
                <sl-menu part="app-menu">
                    ${!this.egw.user('apps')['preferences'] || !this.features.preferences ? nothing : html`
                        <sl-menu-item
                                @click=${() => this.egw.show_preferences('prefs', [this.name])}
                        >
                            <sl-icon slot="prefix" name="gear"></sl-icon>
                            ${this.egw.lang("Preferences")}
                        </sl-menu-item>
                    `}
                    ${!this.egw.user('apps')['preferences'] || !this.features.aclRights ? nothing : html`
                        <sl-menu-item
                                @click=${() => this.egw.show_preferences('acl', [this.name])}
                        >
                            <sl-icon slot="prefix" name="lock"></sl-icon>
                            ${this.egw.lang("Access")}
                        </sl-menu-item>
                    `}
                    ${!this.egw.user('apps')['preferences'] || !this.features.categories ? nothing : html`
                        <sl-menu-item
                                @click=${() => this.features.categories == "1" ?
                                               this.egw.show_preferences('cats', [this.name]) :
                                               this.egw.open_link(<string>this.features.categories, this.name)
                                }
                        >
                            <sl-icon slot="prefix" name="tag"></sl-icon>
                            ${this.egw.lang("Categories")}
                        </sl-menu-item>
                    `}
                    ${this._applicationMenuTemplate()}
                </sl-menu>
            </sl-dropdown>
            <slot name="header-actions"></slot>
            ${this.hasContextMenu ? html`
                <et2-button-icon name="three-dots-vertical" class="egw_fw_app--only_mobile"
                                 nosubmit
                                 label=${this.egw.lang("Context menu")}
                                 statustext=${this.egw.lang("Context menu")}
                                 @click=${this.handleContextButton}
                ></et2-button-icon>` : nothing
            }
		`;
	}

	protected _filterButtonTemplate()
	{
		if(!this.nextmatch)
		{
			return nothing;
		}
		const info = this.getFilterInfo(this.filters?.value ?? {}, this);
		let button : symbol | TemplateResult = nothing;
		if(this.hasSlotController.test("filter"))
		{
			button = html`
                <et2-button-icon nosubmit
                                 name=${info.icon}
                                 label=${this.egw.lang("Filters")}
                                 statustext=${info.tooltip}
                                 @click=${this.handleFilterButtonClick}
                ></et2-button-icon>`;
		}

		// Need hoist to get correct placement
		return html`
            <sl-tooltip content=${info.tooltip}
                        trigger="manual"
                        class="egw_fw_app__filter_info_tooltip"
                        placement="bottom"
                        distance="0"
                        hoist
            >
                ${button}
            </sl-tooltip>
		`
	}

	protected _filterTemplate()
	{
		if(!this.nextmatch && !this.hasSlotController.test("filter"))
		{
			return nothing;
		}

		// Drawer label includes row count
		const info = this.getFilterInfo(this.filters?.value ?? {}, this);
		const hasCustomFilter = this.hasSlotController.test("filter");
		const hasFiltersSet = info.icon == "filter-circle-fill";

		return html`
            <sl-drawer part="filter"
                       exportparts="panel:filter__panel "
                       class="egw_fw_app__filter_drawer"
                       label=${info.tooltip} contained
                       @sl-after-show=${() => this.filters?.shadowRoot?.querySelector(".et2-input-widget")?.focus()}
            >
                ${hasFiltersSet ? html`
                    <et2-button-icon nosubmit
                                     slot="header-actions" name="x-circle-fill"
                                     label="${this.egw.lang("Clear filters")}"
                                     statustext="${this.egw.lang("Clear filters")}"
                                     @click=${e =>
                                     {
                                         this.filters.value = {};
                                         this.filters.applyFilters();
                                     }}
                    ></et2-button-icon>` : nothing
                }
                <et2-button-icon slot="header-actions" name="selectcols"
                                 class="egw_fw_app--no_mobile"
                                 label=${this.egw.lang("Select columns")}
                                 statustext=${this.egw.lang("Select columns")}
                                 @click=${e => {this.nextmatch._selectColumnsClick(e)}} nosubmit>
                </et2-button-icon>
                ${hasCustomFilter ? html`
                    <slot name="filter"></slot>` : nothing}
            </sl-drawer>`;
	}

	/**
	 * This is the application's "Menu" in the top-right corner.
	 * Most of what was in the sidebox now goes here.
	 *
	 * @returns {TemplateResult<1> | typeof nothing }
	 * @protected
	 */
	protected _applicationMenuTemplate()
	{
		if(!this._sideboxData)
		{
			return nothing;
		}

		// Sub-menus call getComputedStyle() so are expensive, defer them until later
		const menuPromise = new Promise(async resolve =>
		{
			await this.loadingPromise;
			setTimeout(() =>
			{
				resolve(
					html`${repeat(this._sideboxData, (menu) => menu['menu_name'], this._applicationMenuItemTemplate.bind(this))}`
				);
			});
		});

		return html`${until(menuPromise, nothing)}`;
	}

	/**
	 * Template for a single item (top level) in the application menu
	 *
	 * @param menu
	 * @return {TemplateResult<1> | typeof nothing}
	 */
	_applicationMenuItemTemplate(menu)
	{
		// No favorites here
		if(menu["title"] == "Favorites" || menu["title"] == this.egw.lang("favorites"))
		{
			return nothing;
		}
		// Just one thing, don't bother with submenu
		if(menu["entries"].length == 1)
		{
			if(["--", "<hr />"].includes(menu["entries"][0]["lang_item"]))
			{
				return this._applicationSubMenuItemTemplate({...menu["entries"][0], lang_item: '<hr />'});
			}
			else
			{
				return this._applicationSubMenuItemTemplate({...menu["entries"][0], lang_item: menu["title"]})
			}
		}
		return html`
            <sl-menu-item exportparts="popup">
                ${menu["title"]}
                ${menu["icon"] ? html`
                    <sl-icon slot="prefix" name="${menu["icon"]}"></sl-icon>` : nothing}
                <sl-menu slot="submenu">
                    ${repeat(menu["entries"], (entry) =>
                    {
                        return this._applicationSubMenuItemTemplate(entry);
                    })}
                </sl-menu>
            </sl-menu-item>`;
	}

	/**
	 * An individual sub-item in the application menu
	 * @param item
	 * @returns {TemplateResult<1>}
	 */
	_applicationSubMenuItemTemplate(item)
	{
		if(item["lang_item"] == "<hr />")
		{
			return html`
                <sl-divider></sl-divider>`;
		}
		let icon : symbol | TemplateResult<1> = nothing;
		if(typeof item["icon"] == "string")
		{
			icon = html`
                <sl-icon name=${item["icon"] ?? nothing} slot="prefix"></sl-icon>`;
		}
		return html`
            <sl-menu-item
                    ?disabled=${!item["item_link"]}
                    data-link=${item["item_link"]}
                    ?data-target=${item["target"]}
                    ?data-popup=${item["popup"]}
                    ?data-app=${item["app"]}
                    @click=${this.handleSideboxMenuClick}
            >
                ${icon}
                ${item["lang_item"]}
            </sl-menu-item>`;

	}

	render()
	{
		const hasLeftSlots = this.hasSideContent("left");
		const hasRightSlots = this.hasSideContent("right");
		const hasHeaderContent = this.hasSlotController?.test("main-header");

		const leftWidth = this.leftCollapsed || !hasLeftSlots ? this.leftPanelInfo.hiddenWidth :
						  this.leftPanelInfo.preferenceWidth;
		const rightWidth = this.rightCollapsed || !hasRightSlots ? this.rightPanelInfo.hiddenWidth :
						   this.rightPanelInfo.preferenceWidth;

		// Disconnected app (about to be removed?)
		if(!this.framework)
		{
			return nothing;
		}

		return html`
            <div class="egw_fw_app__header" part="app-header" title>
                <div class=${classMap({
                    egw_fw_app__name: true,
                    hasHeaderContent: hasHeaderContent,
                })} part="name">
                    ${hasLeftSlots || window.matchMedia("(width < 800px)").matches ? html`
                        <et2-button-icon name="${this.leftCollapsed ? "chevron-double-right" : "chevron-double-left"}"
                                    label="${this.leftCollapsed ? this.egw.lang("Show left area") : this.egw?.lang("Hide left area")}"
                                    @click=${() =>
                                    {
                                        this.leftCollapsed ? this.showLeft() : this.hideLeft();
                                        // Just in case they collapsed it manually, reset
                                        this.leftPanelInfo.preferenceWidth = this.leftPanelInfo.preferenceWidth || this.leftPanelInfo.defaultWidth;
                                        this.requestUpdate("leftCollapsed")
                                    }}
                        ></et2-button-icon>` : nothing
                    }
                    <h2>${this.title || this.egw?.lang(this.name) || this.name}</h2>
                </div>
                <header class="egw_fw_app__header" part="header">
                    <slot name="main-header"></slot>
                </header>
                ${until(this.framework?.getEgwComplete().then(() => this._rightHeaderTemplate()), html`
                    <sl-spinner></sl-spinner>`)}
            </div>
            <div class="egw_fw_app__main" part="main" title>
                ${this.loading ? this._loadingTemplate() : nothing}
                ${this._filterTemplate()}
                ${!this.leftCollapsed ? nothing : html`
                    <style>
                        --left-min: ${this.leftPanelInfo.hiddenWidth}px
                        --left-max: ${this.leftPanelInfo.hiddenWidth}px
                    </style>`}
                <egw-split-panel class=${classMap({
                    "egw_fw_app__panel": true,
                    "egw_fw_app__outerSplit": true,
                    "no-content": !hasLeftSlots,
                    "egw_fw_app--panel-collapsed": this.leftCollapsed
                })}
                                 primary="start" position="${leftWidth}"
                                 snap="0px 20%" snap-threshold="50"
                                 data-panel="leftPanelInfo"
                                 @sl-reposition=${this.handleSlide}
                >
                    <sl-icon slot="divider" name="grip-vertical" @dblclick=${this.hideLeft}></sl-icon>
                    ${this._asideTemplate("start", "left", this.egw.lang("Sidebox"))}
                    <egw-split-panel slot="end"
                                     class=${classMap({
                                         "egw_fw_app__panel": true,
                                         "egw_fw_app__innerSplit": true,
                                         "no-content": !hasRightSlots,
                                         "egw_fw_app--panel-collapsed": this.rightCollapsed
                                     })}
                                     primary="start"
                                     position=${rightWidth} snap="50% 80% 100%"
                                     snap-threshold="50"
                                     data-panel="rightPanelInfo"
                                     data-default=${this.rightPanelInfo.defaultWidth}
                                     @sl-reposition=${this.handleSlide}
                    >
                        ${this.rightCollapsed ? nothing : html`
                            <sl-icon slot="divider" name="grip-vertical" @dblclick=${this.hideRight}></sl-icon>`
                        }
                        <header slot="start" class="egw_fw_app__header header" part="content-header">
                            <slot name="header"></slot>
                        </header>
                        <div slot="start" class="egw_fw_app__main_content content" part="content"
                             aria-label="${this.name}" tabindex="0">
                            <slot></slot>
                        </div>
                        <footer slot="start" class="egw_fw_app__footer footer" part="footer">
                            <slot name="footer"></slot>
                        </footer>
                        ${this._asideTemplate("end", "right", this.egw.lang("%1 application details", this.egw.lang(this.name)))}
                    </egw-split-panel>
                </egw-split-panel>
            </div>
		`;
	}
}

type PanelInfo = {
	side : "left" | "right",
	preference : "app_left_width" | "app_right_width",
	hiddenWidth : number,
	defaultWidth : number,
	preferenceWidth : number | string
}

/* Information for the filter button */
export type FilterInfo = {
	icon : string,
	tooltip : string
}