/**
 * EGroupware eTemplate2 - Email WebComponent
 *
 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
 * @package api
 * @link https://www.egroupware.org
 * @author Nathan Gray
 */
import {html, LitElement, nothing, PropertyValues, render, TemplateResult} from "lit";
import {Et2Widget} from "../Et2Widget/Et2Widget";
import shoelace from "../Styles/shoelace";
import styles from "./Et2Template.styles";
import {property} from "lit/decorators/property.js";
import {customElement} from "lit/decorators/custom-element.js";
import {et2_loadXMLFromURL} from "../et2_core_xml";
import {Et2InputWidgetInterface} from "../Et2InputWidget/Et2InputWidget";
import {IegwAppLocal} from "../../jsapi/egw_global";
import {until} from "lit/directives/until.js";
import {classMap} from "lit/directives/class-map.js";
import {SelectOption} from "../Et2Select/FindSelectOptions";

// @ts-ignore
/**
 * @summary Load & populate a template (.xet file) into the DOM
 *
 * @slot - The template's contents
 * @slot loader - Displayed while the template contents are being loaded instead of the built-in loader
 *
 * @event load - Emitted when all elements are loaded
 *
 * @csspart template - Wrapper around template content
 * @csspart loader - Displayed while the template contents are being loaded
 */
@customElement("et2-template")
export class Et2Template extends Et2Widget(LitElement)
{
	static get styles()
	{
		return [
			shoelace,
			super.styles,
			styles
		];
	}

	/**
	 * Name / ID of template.
	 * It can be full [app].[template_file].[template] form or just the last part.  Templates will be loaded from /[app]/templates/_interface_/[template_file].xet
	 * To use the short form, the file must already be loaded.
	 */
	@property()
	template : string;

	/**
	 * A full URL to load template, optionally including cache-buster ('?'+filemtime of template on server)
	 */
	@property()
	url : string;

	/**
	 * Used as index into content array for passing in specific content to the template other than what it would get by its ID
	 */
	@property()
	content : string;

	@property({type: Function})
	onload : Function;

	/**
	 * Cache of known templates
	 * @type {{[name : string] : Element}}
	 */
	public static templateCache : { [name : string] : Element } = {};
	protected loading : Promise<void>;

	// Used to build template outside the DOM for performance
	private _renderTarget : DocumentFragment | null = null;

	private __egw : IegwAppLocal = null;

	// Internal flag to indicate loading is in progress, since we can't monitor a promise
	private __isLoading = false;


	constructor(egw? : IegwAppLocal)
	{
		super();

		if(egw)
		{
			this.__egw = egw;
		}

		this.loading = Promise.resolve();
	}

	destroy()
	{
		super.destroy();
		this.__egw = null;
	}

	connectedCallback() : void
	{
		super.connectedCallback();
		this.addEventListener("load", this.handleLoad);

		// If we can, start loading immediately but don't re-load if we already have contents (probably a DOM move)
		if(this.childElementCount == 0 && (this.template || this.id || this.url))
		{
			this.load();
		}
	}

	disconnectedCallback() : void
	{
		super.disconnectedCallback();
		this.removeEventListener("load", this.handleLoad);
	}

	async getUpdateComplete() : Promise<boolean>
	{
		const result = await super.getUpdateComplete();
		await this.loading;

		return result;
	}

	willUpdate(changedProperties : PropertyValues)
	{
		// If content index was changed, re-check / create namespace
		if(changedProperties.has("content"))
		{
			this.checkCreateNamespace();
		}

		// Load if template (template, id or URL) or content index changed
		// (as long as we're not currently already loading, to prevent loops if load changes an attribute)
		if(!this.__isLoading && ["template", "id", "url", "content"].filter(v => changedProperties.has(v)).length > 0)
		{
			this.load();
		}
	}

	/**
	 * Searches for a DOM widget by id in the tree, descending into the child levels.
	 *
	 * @param _id is the id you're searching for
	 */
	getDOMWidgetById(_id) : typeof Et2Widget | null
	{
		let widget = this.getWidgetById(_id);
		if(widget && (widget instanceof HTMLElement || widget.instanceOf(Et2Widget)))
		{
			return <typeof Et2Widget>widget;
		}
		return null
	}

	/**
	 * Searches for a Value widget by id in the tree, descending into the child levels.
	 *
	 * @param _id is the id you're searching for
	 */
	getInputWidgetById(_id) : Et2InputWidgetInterface | null
	{
		let widget = <any>this.getWidgetById(_id);
		// instead of checking widget to be an instance of valueWidget (which would create a circular dependency)
		// we check for the interface/methods of valueWidget
		if(widget && typeof widget.get_value === 'function' && typeof widget.set_value === 'function')
		{
			return <Et2InputWidgetInterface>widget;
		}
		return null
	}

	/**
	 * Set the value for a child widget, specified by the given ID
	 *
	 * @param id  string The ID you're searching for
	 * @param value Value for the widget
	 *
	 * @return Returns the result of widget's set_value(), though this is usually undefined
	 *
	 * @throws Error If the widget cannot be found or it does not have a set_value() function
	 */
	setValueById(id : string, value) : any
	{
		let widget = this.getWidgetById(id);
		if(!widget)
		{
			throw 'Could not find widget ' + id;
		}

		// Don't care about what class it is, just that it has the function
		// @ts-ignore
		if(typeof widget.set_value !== 'function')
		{
			throw 'Widget ' + id + ' does not have a set_value() function';
		}

		// @ts-ignore
		return widget.set_value(value);
	}

	/**
	 * Get the current value of a child widget, specified by the given ID
	 *
	 * This is the current value of the widget, which may be different from the original value given in content
	 *
	 * @param id  string The ID you're searching for
	 * @throws Error If the widget cannot be found or it does not have a set_value() function
	 */
	getValueById(id : string)
	{
		let widget = this.getWidgetById(id);
		if(!widget)
		{
			throw 'Could not find widget ' + id;
		}

		// Don't care about what class it is, just that it has the function
		// @ts-ignore
		if(typeof widget.get_value !== 'function' && typeof widget.value == "undefined")
		{
			throw 'Widget ' + id + ' does not have a get_value() function';
		}

		// @ts-ignore
		return typeof widget.get_value == "function" ? widget.get_value() : widget.value;
	}

	/**
	 * Set the value for a child widget, specified by the given ID
	 *
	 * @param id  string The ID you're searching for
	 * @param value new value to set
	 * @throws Error If the widget cannot be found, or it does not have a set_value() function
	 */
	setDisabledById(id : string, value : boolean)
	{
		let widget = this.getWidgetById(id);
		if(!widget)
		{
			throw 'Could not find widget ' + id;
		}

		// Don't care about what class it is, just that it has the function
		// @ts-ignore
		if(typeof widget.set_disabled !== 'function')
		{
			throw 'Widget ' + id + ' does not have a set_disabled() function';
		}

		// @ts-ignore
		return widget.set_disabled(value);
	}

	public egw() : IegwAppLocal
	{
		if(this.__egw)
		{
			return this.__egw;
		}
		else
		{
			return super.egw();
		}
	}

	/**
	 * Get the template XML and create widgets from it
	 *
	 * Asks the server if we don't have that template on the client yet, then takes the template
	 * node and goes through it, creating widgets.  This is normally called automatically when the
	 * template is added to the DOM, but if you want to re-load or not put it in the DOM you need to call load() yourself.
	 *
	 *
	 * @returns {Promise<void>}
	 * @protected
	 */
	public async load(newContent? : object,
					  newSelectOptions? : { [widgetID : string] : SelectOption[] },
					  newReadonlys? : { [widgetID : string] : string | boolean | object },
					  newModifications? : { [widgetID : string] : string | boolean | object })
	{
		// @ts-ignore can't find disabled, it's in Et2Widget
		if(this.disabled)
		{
			this.loading = Promise.resolve();
			return this.loading;
		}
		// No double-loading
		// Can happen if you disconnect/reconnect the template while its loading
		if(this.__isLoading)
		{
			return this.loading;
		}

		if(typeof newContent != "undefined")
		{
			// @ts-ignore ArrayMgr still expects et2_widgets
			this.setArrayMgr("content", this.getArrayMgr("content").openPerspective(this, newContent));
		}
		if(typeof newSelectOptions != "undefined")
		{
			// @ts-ignore ArrayMgr still expects et2_widgets
			this.setArrayMgr("sel_options", this.getArrayMgr("sel_options").openPerspective(this, newSelectOptions));
		}
		if(typeof newReadonlys != "undefined")
		{
			// @ts-ignore ArrayMgr still expects et2_widgets
			this.setArrayMgr("readonlys", this.getArrayMgr("readonlys").openPerspective(this, newReadonlys));
		}
		if(typeof newModifications != "undefined")
		{
			// @ts-ignore ArrayMgr still expects et2_widgets
			this.setArrayMgr("modifications", this.getArrayMgr("modifications").openPerspective(this, newModifications));
		}
		this.__isLoading = true;
		this.loading = <Promise<void>>new Promise(async(resolve, reject) =>
		{
			// No template, no point in continuing
			if(!(this.template || this.id))
			{
				console.debug("No template name, aborting load", this);
				resolve();
				return;
			}

			const templateName = (this.template || this.id);
			if(this.egw().debug_level() >= 4)
			{
				window.performance.mark("mark_et2-template.load " + templateName + " start");
			}

			// Get template XML
			let xml : Element;
			try
			{
				xml = await this.findTemplate();
			}
			catch(e)
			{
				reject(e);
				return;
			}
			// Trying to load with no template, but it has children
			// Something weird happening, maybe DOM copying?
			if(!xml && this.childElementCount)
			{
				resolve();
				return;
			}

			// Empty in case load was called again
			this.clear();

			// Read the XML structure of the requested template
			if(typeof xml != 'undefined')
			{
				// Get any template attributes from XML template node
				const attrs = {};
				xml.getAttributeNames().forEach(attribute =>
				{
					attrs[attribute] = xml.getAttribute(attribute);
				});
				// Don't change ID, keep what we've got
				// only if we have an ID, otherwise further templates in an overlay have no id!
				if (this.id) delete attrs["id"];
				this.transformAttributes(attrs);

				// Build children off-DOM, then append once to avoid repeated live layout work
				this._renderTarget = document.createDocumentFragment();
				try
				{
					this.loadFromXML(xml);
					this.append(this._renderTarget);
				}
				finally
				{
					this._renderTarget = null;
				}
			}
			else
			{
				reject("Could not find template");
				return;
			}

			// Wait for widgets to be complete
			await this.loadFinished();
			console.groupEnd();
			this.__isLoading = false;

			// Resolve promise, this.updateComplete now resolved
			resolve();

			if(this.egw().debug_level() >= 4)
			{
				window.performance.mark("mark_et2-template.load " + templateName + " end");
				window.performance.measure(`et2-template load ${templateName}`, "mark_et2-template.load " + templateName + " start", "mark_et2-template.load " + templateName + " end")
			}

			// Yield to give anything else a chance to run
			setTimeout(() =>
			{
				// Notification event
				this.dispatchEvent(new CustomEvent("load", {
					bubbles: true,
					composed: true,
					detail: this
				}));
			}, 0);
		}).catch(reason =>
		{
			this.loadFailed(reason);
		});
		return this.loading;
	}


	/**
	 * Find the template XML node, either from the local cache or the server
	 *
	 * @returns {Promise<any>}
	 * @protected
	 */
	protected async findTemplate() : Promise<Element>
	{
		// Find template name
		const parts = (this.template || this.id).split('?');
		const cache_buster = parts.length > 1 ? parts.pop() : null;
		let template_name = parts.pop();

		// Check to see if the template is already known / loaded into global ETemplate cache
		let xml = Et2Template.templateCache[template_name];

		// Check to see if ID is short form --> prepend parent/top-level name
		if(!xml && template_name.indexOf('.') < 0)
		{
			const root = this.getRoot();
			const top_name = root && root.getInstanceManager() ? root.getInstanceManager().name : null;
			if(top_name && template_name.indexOf('.') < 0)
			{
				template_name = top_name + '.' + template_name
				xml = Et2Template.templateCache[template_name];
			}
		}

		// Ask the server for the template
		if(!xml)
		{
			const url = this.getUrl();
			let templates = <Element>{};
			if(url)
			{
				try
				{
					templates = await this.loadFromFile(url);
					if(!templates)
					{
						throw new Error("No templates found in template file " + url);
					}
				}
				catch(e)
				{
					throw new Error("Could not load template file " + url);
				}
			}

			// Scan the file for templates and store them
			let fallback;
			for(let i = 0; i < templates.childNodes?.length; i++)
			{
				const template = <Element>templates.childNodes[i];
				if(!["template", "et2-template"].includes(template.nodeName.toLowerCase()))
				{
					continue;
				}
				if(template.getAttribute("id"))
				{
					Et2Template.templateCache[template.getAttribute("id")] = <Element>template;
				}
				if(template.getAttribute("id") == template_name)
				{
					xml = template;
				}
				fallback = template;
			}
			// Take last template in the file if we had no better match
			if(!xml)
			{
				xml = fallback;
			}
		}
		return xml;
	}

	/**
	 * Load the xml from the given file
	 *
	 * Broken out here so it can be stubbed for testing
	 *
	 * @param path
	 * @returns {Promise<Element | void>}
	 * @protected
	 */
	protected loadFromFile(path)
	{
		return et2_loadXMLFromURL(path, null, this);
	}
	/**
	 * The template has been loaded, wait for child widgets to be complete.
	 *
	 * For webComponents, we wait for the widget's updateComplete.
	 * For legacy widgets, we let them finish and wait for their doLoadingFinished Promise
	 *
	 * @protected
	 */
	protected loadFinished()
	{
		// List of Promises from widgets that are not quite fully loaded
		let deferred = [];

		// Inform the widget tree that it has been successfully loaded.
		super.loadingFinished(deferred);

		// Don't wait for ourselves, it will never happen
		deferred = deferred.filter((d) => { return d.widget !== this});

		let ready = false;

		// Wait for everything to be loaded, then finish it up.  Use timeout to give anything else a chance
		// to run.
		return Promise.race([
			Promise.all(deferred).then(() =>
			{
				ready = true;
				deferred.splice(0, deferred.length);

				// Clean up load timeout if it's there, we did eventually finish
				this.querySelector(":scope > #load-error")?.remove();
			}),
			// If loading takes too long, give some feedback so we can try to track down why
			new Promise((resolve) =>
			{
				setTimeout(() =>
					{
						if(ready)
						{
							return;
						}
						this.loadFailed("Load timeout");
						console.debug(this.templateName + " @ " + this.getUrl() + " widget loading took too long.  This is the deferred widget list, look for widgets still pending to find the problem", deferred);
						resolve();
					}, 15000
				);
			})
		]);
	}

	protected clear()
	{
		// Clear
		while(this.firstChild) this.removeChild(this.lastChild);
		this._renderTarget?.replaceChildren();
	}

	addChild(child : any)
	{
		if(!this._renderTarget)
		{
			return super.addChild(child);
		}
		if(this.getChildren().indexOf(child) >= 0)
		{
			return;
		}
		if(child instanceof HTMLElement)
		{
			this._renderTarget.append(child);
		}
		else
		{
			child._parent = this;
			let child_node = null;
			try
			{
				child_node = typeof child.getDOMNode !== "undefined" ? child.getDOMNode(child) : null;
			}
			catch(e)
			{
				// Child did not give up its DOM node nicely but errored instead
			}
			if(child_node && child_node !== this)
			{
				this._renderTarget.append(child_node);
			}
		}
		this.getChildren().push(child);
	}

	loadFailed(reason? : any)
	{
		const message = (this.templateName) + " @ " + this.getUrl() + (reason ? " \n" + reason : "");
		render(this.errorTemplate(message), <HTMLElement><unknown>this);
		this.egw().debug("warn", "Loading failed: " + message);
	}

	protected getUrl()
	{
		if(this.url)
		{
			return this.url;
		}

		let url = "";
		const parts = ((this.template || this.id) + "").split('?');
		const cache_buster = parts.length > 1 ? parts.pop() : ((new Date).valueOf() / 86400 | 0).toString();
		let template_name = this.templateName;

		// Full URL passed as template?
		if(template_name.startsWith(this.egw().webserverUrl) && template_name.endsWith("xet"))
		{
			url = template_name;
		}
		else
		{

			const splitted = template_name.split('.');
			const app = splitted.shift();
			// Not a valid URL, skip out
			if(splitted.length == 0)
			{
				return '';
			}

			// Get theme from parent, if possible
			const re = new RegExp("/templates/([^/]+)/");
			const parentTheme = (this.parentElement?.closest("et2-template")?.getUrl() || "").match(re);
			const theme = parentTheme && parentTheme[1] ? parentTheme[1] : "default";

			url = this.egw().link(
				`/${app}/templates/${theme}/` + splitted.join('.') + ".xet",
				{download: cache_buster}
			);
		}

		// if we have no cache-buster, reload daily
		if(url.indexOf('?') === -1)
		{
			url += '?download=' + cache_buster;
		}
		return url;
	}

	public get app()
	{
		const parts = ((this.template || this.id) + "").split('?');
		const cache_buster = parts.length > 1 ? parts.pop() : null;
		let template_name = parts.pop();

		const splitted = template_name.split('.');
		return splitted.shift() || "";
	}

	public get templateName()
	{
		const parts = ((this.template || this.id) + "").split('?');
		const cache_buster = parts.length > 1 ? parts.pop() : null;
		let template_name = parts.pop() || "";

		return template_name;
	}

	/**
	 * Override parent to support content attribute
	 * Templates always have ID set, but seldom do we want them to
	 * create a namespace based on their ID.
	 */
	checkCreateNamespace()
	{
		if(this.content)
		{
			const old_id = this.id;
			this._widget_id = this.content;
			super.checkCreateNamespace.apply(this, arguments);
			this._widget_id = old_id;
		}
	}

	_createNamespace() : boolean
	{
		return this.content && this.content != this.id;
	}

	handleLoad(event)
	{
		if(this.onload && typeof this.onload == "function")
		{
			// Make sure function gets a reference to the widget
			let args = Array.prototype.slice.call(arguments);
			if(args.indexOf(this) == -1)
			{
				args.push(this);
			}

			return this.onload.apply(this, args);
		}
	}

	loadingTemplate()
	{
		let loading : symbol | TemplateResult = nothing;

		// Use a fake list loader for indexes
		if(this.id?.match?.(/\.(index|list)/))
		{
			loading = html`
                <svg viewBox="0 0 100 100" width="100%" height="100%" preserveAspectRatio="none"
                     xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
                    <style>
                        .header {
                            fill: var(--sl-color-neutral-100, #e8e8e8);
                        }

                        .body {
                            fill: var(--sl-color-neutral-0, #ffffff);
                        }

                        .line {
                            stroke: var(--sl-color-neutral-200, rgba(0, 0, 0, 0.08));
                            stroke-width: 0.15;
                            vector-effect: non-scaling-stroke;
                        }
                    </style>

                    <!-- Wipe animation
					<defs>
						<linearGradient id="shimmer" x1="-1" y1="0" x2="0" y2="0">
							<stop offset="0%" stop-color="transparent"></stop>
							<stop offset="35%" stop-color="transparent"></stop>
							<stop offset="50%" stop-color="var(--sl-color-gray-50)"
								  stop-opacity="0.45"></stop>
							<stop offset="65%" stop-color="transparent"></stop>
							<stop offset="100%" stop-color="transparent"></stop>

							<animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="2 0"
											  dur="2.2s" repeatCount="indefinite"></animateTransform>
						</linearGradient>
					</defs>
					-->

                    <!-- background -->
                    <rect class="body" width="100%" height="100%"></rect>

                    <!-- header -->
                    <rect class="header" width="100%" height="6.5%"></rect>

                    <!-- 15 row separators -->
                    <g class="line">
                        <line x1="0%" y1="12.9%" x2="100%" y2="12.9%"></line>
                        <line x1="0%" y1="19.3%" x2="100%" y2="19.3%"></line>
                        <line x1="0%" y1="25.7%" x2="100%" y2="25.7%"></line>
                        <line x1="0%" y1="32.1%" x2="100%" y2="32.1%"></line>
                        <line x1="0%" y1="38.5%" x2="100%" y2="38.5%"></line>
                        <line x1="0%" y1="44.9%" x2="100%" y2="44.9%"></line>
                        <line x1="0%" y1="51.3%" x2="100%" y2="51.3%"></line>
                        <line x1="0%" y1="57.7%" x2="100%" y2="57.7%"></line>
                        <line x1="0%" y1="64.1%" x2="100%" y2="64.1%"></line>
                        <line x1="0%" y1="70.5%" x2="100%" y2="70.5%"></line>
                        <line x1="0%" y1="76.9%" x2="100%" y2="76.9%"></line>
                        <line x1="0%" y1="83.3%" x2="100%" y2="83.3%"></line>
                        <line x1="0%" y1="89.7%" x2="100%" y2="89.7%"></line>
                        <line x1="0%" y1="96.1%" x2="100%" y2="96.1%"></line>
                    </g>

                    <!-- shimmer overlay -->
                    <rect x="0" y="6.5%" width="100%" height="93.5%" fill="url(#shimmer)"></rect>
                </svg>
			`;
		}
		else
		{
			loading = html`
                <sl-spinner></sl-spinner>`;
		}

		return html`
            <div part="loader" class="template--loading">
                <slot name="loader">${loading}</slot>
            </div>`;
	}

	errorTemplate(errorMessage = "")
	{
		return html`
            <sl-alert id="load-error" variant="warning" open>
                <sl-icon slot="icon" name="exclamation-triangle"></sl-icon>
                <strong>${this.egw().lang("Loading failed")}</strong><br/>
                ${errorMessage}
            </sl-alert>`
	}

	render()
	{
		const classes = {
			template: true,
			'template--disabled': this.disabled,
			'template--readonly': this.readonly
		};
		if(this.app)
		{
			classes["template--app-" + this.app] = true;
		}
		if(this.layout != "none")
		{
			classes["layout-" + this.layout] = true;
			classes["template--layout-" + this.layout] = true;
		}
		return html`
            <div
                    part="base"
                    class=${classMap(classes)}
            >
                ${until(this.loading.then(() => nothing), this.loadingTemplate())}
                <slot></slot>
            </div>`
	}
}