/**
 * \file
 * \defgroup tfclient Tilde Friends Client JS
 * Tilde Friends client-side browser JavaScript.
 * @{
 */

/** \cond */
import {LitElement, html, css, svg} from '/lit/lit-all.min.js';

let cm6;
let gSocket;

let gCurrentFile;
let gFiles = {};
let gApp = {files: {}, emoji: '📦'};
let gEditor;
let gOriginalInput;
let gUnloading;

let kErrorColor = '#dc322f';
let kDisconnectColor = '#f00';
let kStatusColor = '#fff';
/** \endcond */

/** Functions that server-side app code can call through the app object. */
const k_api = {
	setDocument: {args: ['content'], func: api_setDocument},
	postMessage: {args: ['message'], func: api_postMessage},
	error: {args: ['error'], func: api_error},
	localStorageSet: {args: ['key', 'value'], func: api_localStorageSet},
	localStorageGet: {args: ['key'], func: api_localStorageGet},
	requestPermission: {args: ['permission', 'id'], func: api_requestPermission},
	print: {args: ['...'], func: api_print},
	setHash: {args: ['hash'], func: api_setHash},
};

/**
 * Class that represents the top bar
 */
class TfNavigationElement extends LitElement {
	/**
	 * Get Lit Html properties.
	 * @return The properties.
	 */
	static get properties() {
		return {
			credentials: {type: Object},
			permissions: {type: Object},
			show_permissions: {type: Boolean},
			status: {type: Object},
			spark_lines: {type: Object},
			version: {type: Object},
			show_expanded: {type: Boolean},
			identity: {type: String},
			identities: {type: Array},
			names: {type: Object},
		};
	}

	/**
	 * Create a TfNavigationElement instance.
	 */
	constructor() {
		super();
		this.permissions = {};
		this.show_permissions = false;
		this.status = {};
		this.spark_lines = {};
		this.identities = [];
		this.names = {};
	}

	/**
	 * Toggle editor visibility.
	 * @param event The HTML event.
	 */
	toggle_edit(event) {
		event.preventDefault();
		if (editing()) {
			closeEditor();
		} else {
			edit();
		}
	}

	/**
	 * Remove a stored permission.
	 * @param key The permission to reset.
	 */
	reset_permission(key) {
		send({action: 'resetPermission', permission: key});
	}

	/**
	 * Get or create a spark line.
	 * @param key The spark line identifier.
	 * @param options Spark line options.
	 * @return A spark line HTML element.
	 */
	get_spark_line(key, options) {
		if (!this.spark_lines[key]) {
			let spark_line = document.createElement('tf-sparkline');
			spark_line.title = key;
			spark_line.classList.add('w3-bar-item');
			spark_line.style.paddingRight = '0';
			if (options) {
				if (options.max) {
					spark_line.max = options.max;
				}
			}
			this.spark_lines[key] = spark_line;
			this.requestUpdate();
		}
		return this.spark_lines[key];
	}

	/**
	 * Set the active SSB identity for the current application.
	 * @param id The identity.
	 */
	set_active_identity(id) {
		send({action: 'setActiveIdentity', identity: id});
		this.renderRoot.getElementById('id_dropdown').classList.remove('w3-show');
	}

	/**
	 * Create a new SSB identity.
	 */
	create_identity() {
		if (confirm('Are you sure you want to create a new identity?')) {
			send({action: 'createIdentity'});
		}
	}

	/**
	 * Toggle visibility of the ID dropdown.
	 */
	toggle_id_dropdown() {
		this.renderRoot.getElementById('id_dropdown').classList.toggle('w3-show');
	}

	/**
	 * Edit the current identity's SSB profile.
	 */
	edit_profile() {
		window.location.href = '/~core/ssb/#' + this.identity;
	}

	/**
	 * Sign out of the current Tilde Friends user.
	 */
	logout() {
		window.location.href = `/login/logout?return=${encodeURIComponent(url() + hash())}`;
	}

	/**
	 * Render the identity dropdown.
	 * @return Lit HTML.
	 */
	render_identity() {
		let self = this;

		if (this?.credentials?.session?.name) {
			if (this.identities?.length) {
				return html`
					<link type="text/css" rel="stylesheet" href="/static/w3.css" />
					<div class="w3-dropdown-click w3-right" style="max-width: 100%">
						<button
							class="w3-button w3-rest"
							style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap; max-width: 100%"
							id="identity"
							@click=${self.toggle_id_dropdown}
						>
							${self.names[this.identity]}▾
						</button>
						<div
							id="id_dropdown"
							class="w3-dropdown-content w3-bar-block w3-card-4"
							style="max-width: 100%; right: 0"
						>
							<div
								style="position: fixed; left: 0; right: 0; top: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.25); z-index: -100"
								@click=${self.toggle_id_dropdown}
							></div>
							<button
								class="w3-bar-item w3-button w3-border"
								@click=${() => (window.location.href = '/~core/identity')}
							>
								Manage Identities...
							</button>
							<button
								id="edit_profile"
								class="w3-bar-item w3-button w3-border"
								@click=${self.edit_profile}
							>
								Edit Profile...
							</button>
							${this.identities.map(
								(x) => html`
									<button
										class="w3-bar-item w3-button ${x === self.identity
											? 'w3-cyan'
											: ''}"
										@click=${() => self.set_active_identity(x)}
										style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap"
									>
										${self.names[x]}${self.names[x] === x ? '' : html` - ${x}`}
									</button>
								`
							)}
							<button
								class="w3-bar-item w3-button w3-border"
								id="logout"
								@click=${self.logout}
							>
								Logout ${this.credentials.session.name}
							</button>
						</div>
					</div>
				`;
			} else if (
				this.credentials?.session?.name &&
				this.credentials.session.name !== 'guest'
			) {
				return html`
					<link type="text/css" rel="stylesheet" href="/static/w3.css" />
					<button
						class="w3-bar-item w3-button w3-right w3-cyan"
						id="logout"
						@click=${self.logout}
					>
						Logout ${this.credentials.session.name}
					</button>
					<button
						id="create_identity"
						@click=${this.create_identity}
						class="w3-button w3-mobile w3-red w3-right"
					>
						Create an Identity
					</button>
				`;
			} else {
				return html`
					<button
						class="w3-bar-item w3-button w3-right w3-cyan"
						id="logout"
						@click=${self.logout}
					>
						Logout ${this.credentials.session.name}
					</button>
				`;
			}
		} else {
			return html`<a
				class="w3-bar-item w3-cyan w3-right"
				id="login"
				href="/login?return=${url() + hash()}"
				>login</a
			>`;
		}
	}

	/**
	 * Render the permissions popup.
	 * @return Lit HTML.
	 */
	render_permissions() {
		if (this.show_permissions) {
			return html`
				<link type="text/css" rel="stylesheet" href="/static/w3.css" />
				<div
					style="position: absolute; top: 0; padding: 0; margin: 0; z-index: 100; display: flex; justify-content: center; width: 100%"
				>
					<div
						style="background-color: #444; padding: 1em; margin: 0 auto; border-left: 4px solid #fff; border-right: 4px solid #fff; border-bottom: 4px solid #fff"
					>
						<div>This app has the following permissions:</div>
						${Object.keys(this.permissions).map(
							(key) => html`
								<div>
									<span>${key}</span>:
									${this.permissions[key] ? '✅ Allowed' : '❌ Denied'}
									<button
										@click=${() => this.reset_permission(key)}
										class="w3-button w3-red"
										id=${'permission_reset:' + key}
									>
										Reset
									</button>
								</div>
							`
						)}
						<button
							@click=${() => (this.show_permissions = false)}
							class="w3-button w3-blue"
							id="permissions_close"
						>
							Close
						</button>
					</div>
				</div>
			`;
		}
	}

	/**
	 * Clear the current error.
	 */
	clear_error() {
		this.status = {};
	}

	/**
	 * Render the navigation bar.
	 * @return Lit HTML.
	 */
	render() {
		let self = this;
		const k_global_style = css`
			a:link {
				color: #268bd2;
			}

			a:visited {
				color: #6c71c4;
			}

			a:hover {
				color: #859900;
			}

			a:active {
				color: #2aa198;
			}
		`;
		return html`
			<link type="text/css" rel="stylesheet" href="/static/w3.css" />
			<style>
				${k_global_style} .tooltip {
					position: absolute;
					z-index: 1;
					display: none;
					border: 1px solid black;
					padding: 4px;
					color: black;
					background: white;
				}

				.tooltip_parent:hover .tooltip {
					display: inline-block;
				}
			</style>
			<div class="w3-black w3-bar">
				<span
					class="w3-bar-item"
					style="cursor: pointer"
					@click=${() => (this.show_expanded = !this.show_expanded)}
					>😎</span
				>
				<span
					class="w3-bar-item"
					style=${'white-space: nowrap' +
					(this.show_expanded ? '' : '; display: none')}
					title=${this.version?.name +
					' ' +
					Object.entries(this.version || {})
						.filter((x) => ['name', 'number'].indexOf(x[0]) == -1)
						.map((x) => `\n* ${x[0]}: ${x[1]}`)}
					>${this.version?.number}</span
				>
				<a
					class="w3-bar-item"
					accesskey="h"
					@mouseover=${set_access_key_title}
					data-tip="Open home app."
					href="/"
					style="color: #fff; white-space: nowrap"
					>TF</a
				>
				<a
					class="w3-bar-item"
					accesskey="a"
					@mouseover=${set_access_key_title}
					data-tip="Open apps list."
					href="/~core/apps/"
					>apps</a
				>
				<a
					class="w3-bar-item"
					accesskey="e"
					@mouseover=${set_access_key_title}
					data-tip="Toggle the app editor."
					href="#"
					@click=${this.toggle_edit}
					>edit</a
				>
				<a
					class="w3-bar-item"
					accesskey="p"
					@mouseover=${set_access_key_title}
					data-tip="View and change permissions."
					href="#"
					@click=${() => (self.show_permissions = !self.show_permissions)}
					>🎛️</a
				>
				${this.render_permissions()}
				${this.status?.message && !this.status.is_error
					? html`
							<link type="text/css" rel="stylesheet" href="/static/w3.css" />
							<div
								class="w3-bar-item"
								style="color: ${this.status.color ?? kStatusColor}"
							>
								${this.status.message}
							</div>
						`
					: undefined}
				<span class=${this.show_expanded ? '' : 'w3-hide-small'}>
					${Object.keys(this.spark_lines)
						.sort()
						.map((x) => this.spark_lines[x])}
				</span>
				${this.render_identity()}
			</div>
			${this.status?.is_error
				? html`
					<link type="text/css" rel="stylesheet" href="/static/w3.css" />
					<div class="w3-model w3-animate-top" style="position: absolute; left: 50%; transform: translate(-50%); z-index: 1">
						<dijv class="w3-modal-content w3-card-4" style="display: block; padding: 1em">
							<span id="close_error" @click=${self.clear_error} class="w3-button w3-display-topright">&times;</span>
							<div style="color: ${this.status.color ?? kErrorColor}"><b>ERROR:</b><p id="error" style="white-space: pre">${this.status.message}</p></div>
						</div>
					</div>
					`
				: undefined}
		`;
	}
}

/**
 * Create a tf-navigation element.
 */
customElements.define('tf-navigation', TfNavigationElement);

/**
 * A file in the files sidebar.
 */
class TfFilesElement extends LitElement {
	/**
	 * LitElement properties.
	 * @return The properties.
	 */
	static get properties() {
		return {
			current: {type: String},
			files: {type: Object},
			dropping: {type: Number},
			drop_target: {type: String},
		};
	}

	/**
	 * Create a TfFilesElement instance.
	 */
	constructor() {
		super();
		this.files = {};
		this.dropping = 0;
	}

	/**
	 * Select a clicked file.
	 * @param file The file.
	 */
	file_click(file) {
		this.dispatchEvent(
			new CustomEvent('file_click', {
				detail: {
					file: file,
				},
				bubbles: true,
				composed: true,
			})
		);
	}

	/**
	 * Render a single file in the file list.
	 * @param file The file.
	 * @return Lit HTML.
	 */
	render_file(file) {
		let classes = ['file'];
		if (file == this.current) {
			classes.push('current');
		}
		if (!this.files[file].clean) {
			classes.push('dirty');
		}
		if (this.drop_target == file) {
			classes.push('drop');
		}
		return html`<div
			class="${classes.join(' ')}"
			@click=${(x) => this.file_click(file)}
		>
			${file}
		</div>`;
	}

	/**
	 * Create a file entry for a dropped file.
	 * @param event The event.
	 */
	async drop(event) {
		event.preventDefault();
		event.stopPropagation();
		this.dropping = 0;
		this.drop_target = undefined;
		for (let file of event.dataTransfer.files) {
			let buffer = await file.arrayBuffer();
			let text = new TextDecoder('latin1').decode(buffer);
			gFiles[file.name] = {
				doc: cm6.EditorState.create({
					doc: text,
					extensions: cm6.extensions,
				}),
				buffer: buffer,
				isNew: true,
			};
			gCurrentFile = file.name;
		}
		openFile(gCurrentFile);
		updateFiles();
	}

	/**
	 * Called when a file starts being dragged over the file.
	 * @param event The event.
	 */
	drag_enter(event) {
		this.dropping++;
		this.drop_target = event.srcElement.innerText.trim();
		event.preventDefault();
	}

	/**
	 * Called when a file stops being dragged over the file.
	 * @param event The event.
	 */
	drag_leave(event) {
		this.dropping--;
		if (this.dropping == 0) {
			this.drop_target = undefined;
		}
	}

	/**
	 * Called when a file is being dragged over the file.
	 * @param event The event.
	 */
	drag_over(event) {
		event.preventDefault();
	}

	/**
	 * Render the file.
	 * @return Lit HTML.
	 */
	render() {
		let self = this;
		return html`
			<style>
				div.file {
					padding: 0.5em;
					cursor: pointer;
				}
				div.file:hover {
					background-color: #1a9188;
				}
				div.file::before {
					content: '📄 ';
				}

				div.file.current {
					font-weight: bold;
					background-color: #2aa198;
				}

				div.file.drop {
					border: 4px solid red;
				}

				div.file.dirty::after {
					content: '*';
				}
			</style>
			<div
				@drop=${this.drop}
				@dragenter=${this.drag_enter}
				@dragleave=${this.drag_leave}
				@dragover=${this.drag_over}
			>
				${Object.keys(this.files)
					.sort()
					.map((x) => self.render_file(x))}
			</div>
		`;
	}
}

customElements.define('tf-files', TfFilesElement);

/**
 * The files pane element.
 */
class TfFilesPaneElement extends LitElement {
	/**
	 * Get Lit Html properties.
	 * @return The properties.
	 */
	static get properties() {
		return {
			expanded: {type: Boolean},
			current: {type: String},
			files: {type: Object},
		};
	}

	/**
	 * Create a TfFilesPaneElement instance.
	 */
	constructor() {
		super();
		this.expanded = window.localStorage.getItem('files') != '0';
		this.files = {};
	}

	/**
	 * Set whether the files pane is expanded.
	 * @param expanded Whether the files pane is expanded.
	 */
	set_expanded(expanded) {
		this.expanded = expanded;
		window.localStorage.setItem('files', expanded ? '1' : '0');
	}

	/**
	 * Render the files pane element.
	 * @return Lit HTML.
	 */
	render() {
		let self = this;
		let expander = this.expanded
			? html`<div class="w3-button w3-bar-item w3-blue" style="flex: 0 0 auto; display: flex; flex-direction: row" @click=${() => self.set_expanded(false)}>
				<span style="flex: 1 1" font-weight: bold; text-align: center; flex: 1">Files</span>
				<span style="flex: 0 0">«</span>
			</div>`
			: html`<div
					class="w3-button w3-bar-item w3-blue"
					@click=${() => self.set_expanded(true)}
				>
					»
				</div>`;
		let content = html`
			<tf-files
				style="flex: 1 1; overflow: auto"
				.files=${self.files}
				current=${self.current}
				@file_click=${(event) => openFile(event.detail.file)}
			></tf-files>
			<div>
				<button
					class="w3-bar-item w3-button w3-blue"
					style="width: 100%; flex: 0 0"
					@click=${() => newFile()}
					accesskey="n"
					@mouseover=${set_access_key_title}
					data-tip="Add a new, empty file to the app"
				>
					📄 New File
				</button>
			</div>
			<div>
				<button
					class="w3-bar-item w3-button w3-blue"
					style="width: 100%; flex: 0 0"
					@click=${() => removeFile()}
					accesskey="r"
					@mouseover=${set_access_key_title}
					data-tip="Remove the selected file from the app"
				>
					🚮 Remove File
				</button>
			</div>
		`;
		return html`
			<link type="text/css" rel="stylesheet" href="/static/w3.css" />
			<div style="display: flex; flex-direction: column; height: 100%">
				${expander} ${this.expanded ? content : undefined}
			</div>
		`;
	}
}

customElements.define('tf-files-pane', TfFilesPaneElement);

/**
 * A tiny graph.
 */
class TfSparkLineElement extends LitElement {
	static get properties() {
		return {
			lines: {type: Array},
			min: {type: Number},
			max: {type: Number},
		};
	}

	constructor() {
		super();
		this.min = 0;
		this.max = 1.0;
		this.lines = [];
		this.k_values_max = 100;
	}

	/**
	 * Add a data point to the graph.
	 * @param key The line to which the point applies.
	 * @param value The numeric value of the data point.
	 */
	append(key, value) {
		let line = null;
		for (let it of this.lines) {
			if (it.name == key) {
				line = it;
				break;
			}
		}
		if (!line) {
			const k_colors = ['#0f0', '#88f', '#ff0', '#f0f', '#0ff', '#f00', '#888'];
			line = {
				name: key,
				style: k_colors[this.lines.length % k_colors.length],
				values: Array(this.k_values_max).fill(0),
			};
			this.lines.push(line);
		}
		if (line.values.length >= this.k_values_max) {
			line.values.shift();
		}
		line.values.push(value);
		this.requestUpdate();
	}

	/**
	 * Render a single series line.
	 * @param line The line data.
	 * @return Lit HTML.
	 */
	render_line(line) {
		if (line?.values?.length >= 2) {
			let max = Math.max(this.max, ...line.values);
			let points = [].concat(
				...line.values.map((x, i) => [
					(50.0 * i) / (line.values.length - 1),
					10.0 - (10.0 * (x - this.min)) / (max - this.min),
				])
			);
			return svg`<polyline points=${points.join(' ')} stroke=${line.style} fill="none"/>`;
		}
	}

	/**
	 * Render the graph.
	 * @return Lit HTML.
	 */
	render() {
		let max =
			Math.round(
				10.0 *
					Math.max(
						...this.lines.map((line) => line.values[line.values.length - 1])
					)
			) / 10.0;
		return html`
			<svg
				style="max-width: 7.5em; margin: 0; padding: 0; background: #000; height: 1em"
				viewBox="0 0 50 10"
				xmlns="http://www.w3.org/2000/svg"
			>
				${this.lines.map((x) => this.render_line(x))}
				<text x="0" y="1em" style="font: 8px sans-serif; fill: #fff">
					${this.dataset.emoji}${max}
				</text>
			</svg>
		`;
	}
}

customElements.define('tf-sparkline', TfSparkLineElement);

/**
 *  A keyboard key is pressed down.
 */
window.addEventListener('keydown', function (event) {
	if (event.keyCode == 83 && (event.altKey || event.ctrlKey)) {
		if (editing()) {
			save();
			event.preventDefault();
		}
	} else if (event.keyCode == 66 && event.altKey) {
		if (editing()) {
			closeEditor();
			event.preventDefault();
		}
	}
});

/**
 * Make sure a set of dependencies are loaded
 * @param nodes An array of descriptions of dependencies to load.
 * @param callback Called when all dependencies are loaded.
 */
function ensureLoaded(nodes, callback) {
	if (!nodes.length) {
		callback();
		return;
	}

	let search = nodes.shift();
	let head = document.head;
	let found = false;
	for (let i = 0; i < head.childNodes.length; i++) {
		if (head.childNodes[i].tagName == search.tagName) {
			let match = true;
			for (let attribute in search.attributes) {
				if (
					head.childNodes[i].attributes[attribute].value !=
					search.attributes[attribute]
				) {
					match = false;
				}
			}
			if (match) {
				found = true;
				break;
			}
		}
	}
	if (found) {
		ensureLoaded(nodes, callback);
	} else {
		let node = document.createElement(search.tagName);
		node.onreadystatechange = node.onload = function () {
			ensureLoaded(nodes, callback);
		};
		for (let attribute in search.attributes) {
			node.setAttribute(attribute, search.attributes[attribute]);
		}
		head.insertBefore(node, head.firstChild);
	}
}

/**
 * Check whether the editior is currently visible.
 * @return true if the editor is visible.
 */
function editing() {
	return document.getElementById('editPane').style.display != 'none';
}

/**
 * Check whether only the editor is visible and the app is hidden.
 * @return true if the editor is visible and the app is not.
 */
function is_edit_only() {
	return window.location.search == '?editonly=1' || window.innerWidth < 1024;
}

/**
 * Show the editor.
 */
async function edit() {
	if (editing()) {
		return;
	}

	window.localStorage.setItem('editing', '1');
	document.getElementById('editPane').style.display = 'flex';
	document.getElementById('viewPane').style.display = is_edit_only()
		? 'none'
		: 'flex';

	try {
		if (!gEditor) {
			cm6 = await import('/codemirror/cm6.js');
			gEditor = cm6.TildeFriendsEditorView(document.getElementById('editor'));
		}
		gEditor.onDocChange = updateFiles;
		await load();
	} catch (error) {
		alert(`${error.message}\n\n${error.stack}`);
		closeEditor();
	}
}

/**
 * Open a performance trace.
 */
function trace() {
	window.open(`/speedscope/#profileURL=${encodeURIComponent('/trace')}`);
}

/**
 * Load a single file.
 * @param name The name by which the file is known.
 * @param id The file's ID.
 * @return A promise resolved with the file's contents.
 */
function loadFile(name, id) {
	return fetch('/' + id + '/view')
		.then(function (response) {
			if (!response.ok) {
				alert(
					`Request failed for ${name}: ${response.status} ${response.statusText}`
				);
				return 'missing file!';
			}
			return response.text();
		})
		.then(function (text) {
			gFiles[name].doc = cm6.EditorState.create({
				doc: text,
				extensions: cm6.extensions,
			});
			gFiles[name].original = gFiles[name].doc.doc.toString();
			if (!Object.values(gFiles).some((x) => !x.doc)) {
				openFile(Object.keys(gFiles).sort()[0]);
			}
		});
}

/**
 * Load files for the app.
 * @param path The app path to load.
 * @return A promise resolved when the app is laoded.
 */
async function load(path) {
	let response = await fetch((path || url()) + 'view');
	let json;
	if (response.ok) {
		json = await response.json();
	} else if (response.status != 404) {
		throw new Error(response.status + ' ' + response.statusText);
	}
	gFiles = {};
	let isApp = false;
	let promises = [];

	if (json && json['type'] == 'tildefriends-app') {
		isApp = true;
		Object.keys(json['files']).forEach(function (name) {
			gFiles[name] = {};
			promises.push(loadFile(name, json['files'][name]));
		});
		if (Object.keys(json['files']).length == 0) {
			document.getElementById('editPane').style.display = 'flex';
		}
		gApp = json;
		gApp.emoji = gApp.emoji || '📦';
		document.getElementById('icon').innerHTML = gApp.emoji;
	}
	if (!isApp) {
		document.getElementById('editPane').style.display = 'flex';
		let text = '// New script.\n';
		gCurrentFile = 'app.js';
		gFiles[gCurrentFile] = {
			doc: cm6.EditorState.create({doc: text, extensions: cm6.extensions}),
		};
		openFile(gCurrentFile);
	}
	return Promise.all(promises);
}

/**
 * Hide the editor.
 */
function closeEditor() {
	window.localStorage.setItem('editing', '0');
	document.getElementById('editPane').style.display = 'none';
	document.getElementById('viewPane').style.display = 'flex';
}

/**
 * Save the app.
 * @param save_to An optional path to which to save the app.
 * @return A promise resoled when the app is saved.
 */
function save(save_to) {
	document.getElementById('save').disabled = true;
	if (gCurrentFile) {
		gFiles[gCurrentFile].doc = gEditor.state;
		if (
			!gFiles[gCurrentFile].isNew &&
			!gFiles[gCurrentFile].doc.doc.toString() == gFiles[gCurrentFile].original
		) {
			delete gFiles[gCurrentFile].buffer;
		}
	}

	let save_path = save_to;
	if (!save_path) {
		let name = document.getElementById('name');
		if (name && name.value) {
			save_path = name.value;
		} else {
			save_path = url();
		}
	}

	let promises = [];
	for (let name of Object.keys(gFiles)) {
		let file = gFiles[name];
		if (!file.isNew && file.doc.doc.toString() == file.original) {
			continue;
		}

		delete file.id;
		delete file.isNew;
		promises.push(
			fetch('/save', {
				method: 'POST',
				headers: {
					'Content-Type': 'application/binary',
				},
				body: file.buffer ?? file.doc.doc.toString(),
			})
				.then(function (response) {
					if (!response.ok) {
						throw new Error(
							'Saving "' +
								name +
								'": ' +
								response.status +
								' ' +
								response.statusText
						);
					}
					return response.text();
				})
				.then(function (text) {
					file.id = text;
					if (file.id.charAt(0) == '/') {
						file.id = file.id.substr(1);
					}
				})
		);
	}

	return Promise.all(promises)
		.then(function () {
			let app = {
				type: 'tildefriends-app',
				files: Object.fromEntries(
					Object.keys(gFiles).map((x) => [x, gFiles[x].id || gApp.files[x]])
				),
				emoji: gApp.emoji || '📦',
			};
			Object.values(gFiles).forEach(function (file) {
				delete file.id;
			});
			gApp = JSON.parse(JSON.stringify(app));

			return fetch(save_path + 'save', {
				method: 'POST',
				headers: {
					'Content-Type': 'application/json',
				},
				body: JSON.stringify(app),
			}).then(function (response) {
				if (!response.ok) {
					throw new Error(response.status + ' ' + response.statusText);
				}

				if (save_path != window.location.pathname) {
					alert('Saved to ' + save_path + '.');
				} else if (!gFiles['app.js']) {
					window.location.reload();
				} else {
					reconnect(save_path);
				}
			});
		})
		.catch(function (error) {
			alert(error);
		})
		.finally(function () {
			document.getElementById('save').disabled = false;
			Object.values(gFiles).forEach(function (file) {
				file.original = file.doc.doc.toString();
			});
			updateFiles();
		});
}

/**
 * Prompt to set the app icon.
 */
function changeIcon() {
	let value = prompt('Enter a new app icon emoji:');
	if (value !== undefined) {
		gApp.emoji = value || '📦';
		document.getElementById('icon').innerHTML = gApp.emoji;
	}
}

/**
 * Prompt to delete the current app.
 */
function deleteApp() {
	let name = document.getElementById('name');
	let path = name && name.value ? name.value : url();

	if (confirm(`Are you sure you want to delete the app '${path}'?`)) {
		fetch(path + 'delete')
			.then(function (response) {
				if (!response.ok) {
					throw new Error(response.status + ' ' + response.statusText);
				}
				alert('Deleted.');
			})
			.catch(function (error) {
				alert(error);
			});
	}
}

/**
 * Get the current app URL.
 * @return The app URL.
 */
function url() {
	let hash = window.location.href.indexOf('#');
	let question = window.location.href.indexOf('?');
	let end = -1;
	if (hash != -1 && (hash < end || end == -1)) {
		end = hash;
	}
	if (question != -1 && (question < end || end == -1)) {
		end = question;
	}
	return end != -1
		? window.location.href.substring(0, end)
		: window.location.href;
}

/**
 * Get the window hash without the lone '#' if it is empty.
 * @return The hash.
 */
function hash() {
	return window.location.hash != '#' ? window.location.hash : '';
}

/**
 * Set the iframe document contents.
 * @param content The contents.
 */
function api_setDocument(content) {
	let iframe = document.getElementById('document');
	iframe.srcdoc = content;
}

/**
 * Send a message to the sandboxed iframe.
 * @param message The message.
 */
function api_postMessage(message) {
	let iframe = document.getElementById('document');
	iframe.contentWindow.postMessage(message, '*');
}

/**
 * Show an error.
 * @param error The error.
 */
function api_error(error) {
	if (error) {
		if (typeof error == 'string') {
			setStatusMessage('⚠️ ' + error, kErrorColor);
		} else {
			setStatusMessage('⚠️ ' + error.message + '\n' + error.stack, kErrorColor);
		}
	}
	console.log('error', error);
}

/**
 et a value in local storage.
 * @param key The key.
 * @param value The value.
 */
function api_localStorageSet(key, value) {
	window.localStorage.setItem('app:' + key, value);
}

/**
 * Get a value from local storage.
 * @param key The key.
 * @return The value.
 */
function api_localStorageGet(key) {
	return window.localStorage.getItem('app:' + key);
}

/**
 * Request a permission
 * @param permission The permission to request.
 * @param id The id requeesting the permission.
 * @return A promise fulfilled if the permission was granted.
 */
function api_requestPermission(permission, id) {
	let outer = document.createElement('div');
	outer.classList.add('permissions');

	let container = document.createElement('div');
	container.classList.add('permissions_contents');

	let div = document.createElement('div');
	div.appendChild(
		document.createTextNode('This app is requesting the following permission: ')
	);
	let span = document.createElement('span');
	span.style = 'font-weight: bold';
	span.appendChild(document.createTextNode(permission));
	div.appendChild(span);
	container.appendChild(div);

	div = document.createElement('div');
	div.style = 'padding: 1em';
	let check = document.createElement('input');
	check.id = 'permissions_remember_check';
	check.type = 'checkbox';
	check.classList.add('w3-check');
	check.classList.add('w3-blue');
	div.appendChild(check);
	div.appendChild(document.createTextNode(' '));
	let label = document.createElement('label');
	label.htmlFor = check.id;
	label.appendChild(document.createTextNode('Remember this decision.'));
	div.appendChild(label);
	container.appendChild(div);

	const k_options = [
		{
			id: 'allow',
			text: '✅ Allow',
			grant: ['allow once', 'allow'],
		},
		{
			id: 'deny',
			text: '❌ Deny',
			grant: ['deny once', 'deny'],
		},
	];

	return new Promise(function (resolve, reject) {
		div = document.createElement('div');
		for (let option of k_options) {
			let button = document.createElement('button');
			button.classList.add('w3-button');
			button.classList.add('w3-blue');
			button.innerText = option.text;
			button.id = option.id;
			button.onclick = function () {
				resolve(option.grant[check.checked ? 1 : 0]);
				document.body.removeChild(outer);
			};
			div.appendChild(button);
		}
		container.appendChild(div);
		outer.appendChild(container);

		document.body.appendChild(outer);
	});
}

/**
 * Log from the app to the console.
 */
function api_print() {
	console.log('app>', ...arguments);
}

/**
 * Set the window's location hash.
 * @param hash The new hash.
 */
function api_setHash(hash) {
	window.location.hash = hash;
}

/**
 * Process an incoming WebSocket message.
 * @param message The message.
 */
function _receive_websocket_message(message) {
	if (message && message.action == 'session') {
		setStatusMessage('🟢 Executing...', kStatusColor);
		let navigation = document.getElementsByTagName('tf-navigation')[0];
		navigation.credentials = message.credentials;
		navigation.identities = message.identities;
		navigation.identity = message.identity;
		navigation.names = message.names;
	} else if (message && message.action == 'permissions') {
		let navigation = document.getElementsByTagName('tf-navigation')[0];
		navigation.permissions = message.permissions ?? {};
	} else if (message && message.action == 'identities') {
		let navigation = document.getElementsByTagName('tf-navigation')[0];
		navigation.identities = message.identities;
		navigation.identity = message.identity;
		navigation.names = message.names;
	} else if (message && message.action == 'ready') {
		setStatusMessage(null);
		if (window.location.hash) {
			send({event: 'hashChange', hash: window.location.hash});
		}
		document.getElementsByTagName('tf-navigation')[0].version = message.version;
		document.getElementById('viewPane').style.display = message.edit_only
			? 'none'
			: 'flex';
	} else if (message && message.action == 'ping') {
		send({action: 'pong'});
	} else if (message && message.action == 'stats') {
		let now = new Date().getTime();
		for (let key of Object.keys(message.stats)) {
			const k_groups = {
				rpc_in: {group: 'rpc', name: 'in'},
				rpc_out: {group: 'rpc', name: 'out'},

				cpu_percent: {group: 'cpu', name: 'main'},
				thread_percent: {group: 'cpu', name: 'work'},

				arena_percent: {group: 'memory', name: 'm'},
				js_malloc_percent: {group: 'memory', name: 'js'},
				memory_percent: {group: 'memory', name: 'tot'},
				sqlite3_memory_percent: {group: 'memory', name: 'sql'},
				tf_malloc_percent: {group: 'memory', name: 'tf'},
				tls_malloc_percent: {group: 'memory', name: 'tls'},
				uv_malloc_percent: {group: 'memory', name: 'uv'},

				messages_stored: {group: 'store', name: 'messages'},
				blobs_stored: {group: 'store', name: 'blobs'},

				socket_count: {group: 'socket', name: 'total'},
				socket_open_count: {group: 'socket', name: 'open'},

				import_count: {group: 'functions', name: 'imports'},
				export_count: {group: 'functions', name: 'exports'},
			};
			const k_colors = ['#0f0', '#88f', '#ff0', '#f0f', '#0ff', '#f00', '#888'];
			let graph_key = k_groups[key]?.group || key;
			if (['cpu', 'rpc', 'store', 'memory'].indexOf(graph_key) != -1) {
				let line = document
					.getElementsByTagName('tf-navigation')[0]
					.get_spark_line(graph_key, {max: 100});
				line.dataset.emoji = {
					cpu: '💻',
					rpc: '🔁',
					store: '💾',
					memory: '🐏',
				}[graph_key];
				line.append(key, message.stats[key]);
			}
		}
	} else if (message && message.action === 'tfrpc' && message.method) {
		let api = k_api[message.method];
		let id = message.id;
		let params = message.params;
		if (api) {
			Promise.resolve(api.func(...params))
				.then(function (result) {
					send({
						action: 'tfrpc',
						id: id,
						result: result,
					});
				})
				.catch(function (error) {
					send({
						action: 'tfrpc',
						id: id,
						error: error,
					});
				});
		}
	}
}

/**
 * Set the status message.
 * @param message The message.
 * @param color The message's color.
 */
function setStatusMessage(message, color) {
	document.getElementsByTagName('tf-navigation')[0].status = {
		message: message,
		color: color,
		is_error: color == kErrorColor,
	};
}

/**
 * Send a message to the app.
 * @param value The message.
 */
function send(value) {
	try {
		if (gSocket && gSocket.readyState == gSocket.OPEN) {
			gSocket.send(JSON.stringify(value));
		}
	} catch (error) {
		setStatusMessage('🤷 Send failed: ' + error.toString(), kErrorColor);
	}
}

/**
 * Notify the app of the window hash changing.
 */
function hashChange() {
	send({event: 'hashChange', hash: window.location.hash});
}

/**
 * Make sure the app is connected on window focus, and notify the app.
 */
function focus() {
	if (gSocket && gSocket.readyState == gSocket.CLOSED) {
		connectSocket();
	} else {
		send({event: 'focus'});
	}
}

/**
 * Notify the app of lost focus.
 */
function blur() {
	if (gSocket && gSocket.readyState == gSocket.OPEN) {
		send({event: 'blur'});
	}
}

/**
 * Handle a message.
 * @param event The message.
 */
function message(event) {
	if (
		event.data &&
		event.data.event == 'resizeMe' &&
		event.data.width &&
		event.data.height
	) {
		let iframe = document.getElementById('iframe_' + event.data.name);
		iframe.setAttribute('width', event.data.width);
		iframe.setAttribute('height', event.data.height);
	} else if (event.data && event.data.action == 'setHash') {
		window.location.hash = event.data.hash;
	} else if (event.data && event.data.action == 'storeBlob') {
		fetch('/save', {
			method: 'POST',
			headers: {
				'Content-Type': 'application/binary',
			},
			body: event.data.blob.buffer,
		})
			.then(function (response) {
				if (!response.ok) {
					throw new Error(response.status + ' ' + response.statusText);
				}
				return response.text();
			})
			.then(function (text) {
				let iframe = document.getElementById('document');
				iframe.contentWindow.postMessage(
					{
						storeBlobComplete: {
							name: event.data.blob.name,
							path: text,
							type: event.data.blob.type,
							context: event.data.context,
						},
					},
					'*'
				);
			});
	} else {
		send({event: 'message', message: event.data});
	}
}

/**
 * Reconnect the WebSocket.
 * @param path The path to which the WebSocket should be connected.
 */
function reconnect(path) {
	let oldSocket = gSocket;
	gSocket = null;
	if (oldSocket) {
		oldSocket.onopen = null;
		oldSocket.onclose = null;
		oldSocket.onmessage = null;
		oldSocket.close();
	}
	connectSocket(path);
}

/**
 * Connect the WebSocket.
 * @param path The path to which to connect.
 */
function connectSocket(path) {
	if (!gSocket || gSocket.readyState != gSocket.OPEN) {
		if (gSocket) {
			gSocket.onopen = null;
			gSocket.onclose = null;
			gSocket.onmessage = null;
			gSocket.close();
		}
		setStatusMessage('⚪ Connecting...', kStatusColor);
		gSocket = new WebSocket(
			(window.location.protocol == 'https:' ? 'wss://' : 'ws://') +
				window.location.hostname +
				(window.location.port.length ? ':' + window.location.port : '') +
				'/app/socket'
		);
		gSocket.onopen = function () {
			setStatusMessage('🟡 Authenticating...', kStatusColor);
			let connect_path = path ?? window.location.pathname;
			gSocket.send(
				JSON.stringify({
					action: 'hello',
					path: connect_path,
					url: window.location.href,
					edit_only: editing() && is_edit_only(),
					api: Object.entries(k_api).map(([key, value]) =>
						[].concat([key], value.args)
					),
				})
			);
		};
		gSocket.onmessage = function (event) {
			_receive_websocket_message(JSON.parse(event.data));
		};
		gSocket.onclose = function (event) {
			if (gUnloading) {
				setStatusMessage('⚪ Closing...', kStatusColor);
			} else {
				const k_codes = {
					1000: 'Normal closure',
					1001: 'Going away',
					1002: 'Protocol error',
					1003: 'Unsupported data',
					1005: 'No status received',
					1006: 'Abnormal closure',
					1007: 'Invalid frame payload data',
					1008: 'Policy violation',
					1009: 'Message too big',
					1010: 'Missing extension',
					1011: 'Internal error',
					1012: 'Service restart',
					1013: 'Try again later',
					1014: 'Bad gateway',
					1015: 'TLS handshake',
				};
				setStatusMessage(
					'🔴 Closed: ' + (k_codes[event.code] || event.code),
					kDisconnectColor
				);
			}
		};
	}
}

/**
 * Open a file by name.
 * @param name The file to open.
 */
function openFile(name) {
	let newDoc =
		name && gFiles[name]
			? gFiles[name].doc
			: cm6.EditorState.create({doc: '', extensions: cm6.extensions});
	let oldDoc = gEditor.state;
	gEditor.setState(newDoc);

	if (gFiles[gCurrentFile]) {
		gFiles[gCurrentFile].doc = oldDoc;
		if (
			!gFiles[gCurrentFile].isNew &&
			gFiles[gCurrentFile].doc.doc.toString() == oldDoc.doc.toString()
		) {
			delete gFiles[gCurrentFile].buffer;
		}
	}
	gCurrentFile = name;
	updateFiles();
	gEditor.focus();
}

/**
 * Refresh the files list.
 */
function updateFiles() {
	let files = document.getElementsByTagName('tf-files-pane')[0];
	if (files) {
		files.files = Object.fromEntries(
			Object.keys(gFiles).map((file) => [
				file,
				{
					clean:
						(file == gCurrentFile
							? gEditor.state.doc.toString()
							: gFiles[file].doc.doc.toString()) == gFiles[file].original,
				},
			])
		);
		files.current = gCurrentFile;
	}
	gEditor.focus();
}

/**
 * Create a new file with the given name.
 * @param name The file's name.
 */
function makeNewFile(name) {
	gFiles[name] = {
		doc: cm6.EditorState.create({extensions: cm6.extensions}),
	};
	openFile(name);
}

/**
 * Prompt to create a new file.
 */
function newFile() {
	let name = prompt('Name of new file:', 'file.js');
	if (name && !gFiles[name]) {
		makeNewFile(name);
	}
}

/**
 * Prompt to remove a file.
 */
function removeFile() {
	if (confirm('Remove ' + gCurrentFile + '?')) {
		delete gFiles[gCurrentFile];
		openFile(Object.keys(gFiles)[0]);
	}
}

/**
 * Export the app to a zip file, which is downloaded by the browser.
 */
async function appExport() {
	let JsZip = (await import('/static/jszip.min.js')).default;
	let owner = window.location.pathname.split('/')[1].replace('~', '');
	let name = window.location.pathname.split('/')[2];
	let zip = new JsZip();
	zip.file(
		`${name}.json`,
		JSON.stringify({
			type: 'tildefriends-app',
			emoji: gApp.emoji || '📦',
		})
	);
	for (let file of Object.keys(gFiles)) {
		zip.file(
			`${name}/${file}`,
			gFiles[file].buffer ?? gFiles[file].doc.doc.toString()
		);
	}
	let content = await zip.generateAsync({
		type: 'base64',
		compression: 'DEFLATE',
	});
	let a = document.createElement('a');
	a.href = `data:application/zip;base64,${content}`;
	a.download = `${owner}_${name}.zip`;
	a.click();
}

/**
 * Save a file.
 * @param name The file to svae.
 * @param file The file contents.
 * @return A promise resolved with the blob ID of the saved file.
 */
async function save_file_to_blob_id(name, file) {
	console.log(`Saving ${name}.`);
	let response = await fetch('/save', {
		method: 'POST',
		headers: {
			'Content-Type': 'application/binary',
		},
		body: file,
	});
	if (!response.ok) {
		throw new Error(
			'Saving "' + name + '": ' + response.status + ' ' + response.statusText
		);
	}
	let blob_id = await response.text();
	if (blob_id.charAt(0) == '/') {
		blob_id = blob_id.substr(1);
	}
	return blob_id;
}

/**
 * Prompt to import an app from a zip file.
 */
async function appImport() {
	let JsZip = (await import('/static/jszip.min.js')).default;
	let input = document.createElement('input');
	input.type = 'file';
	input.click();
	input.onchange = async function () {
		try {
			for (let file of input.files) {
				if (file.type != 'application/zip') {
					console.log(`This does not look like a .zip (${file.type}).`);
					continue;
				}
				let buffer = new Uint8Array(await file.arrayBuffer());
				console.log(
					'ZIP',
					file.name,
					file.type,
					buffer,
					buffer?.byteLength,
					buffer?.length
				);
				let zip = new JsZip();
				await zip.loadAsync(buffer);
				let app_object;
				let app_name;
				for (let [name, object] of Object.entries(zip.files)) {
					if (name.endsWith('.json') && name.indexOf('/') == -1) {
						try {
							let parsed = JSON.parse(await object.async('text'));
							if (parsed.type == 'tildefriends-app') {
								app_object = parsed;
								app_name = name.substring(0, name.length - '.json'.length);
								break;
							}
						} catch (e) {
							console.log(e);
						}
					}
				}
				if (app_object) {
					app_object.files = {};
					for (let [name, object] of Object.entries(zip.files)) {
						if (!name.startsWith(app_name + '/') || name.endsWith('/')) {
							continue;
						}
						app_object.files[name.substring(app_name.length + '/'.length)] =
							await save_file_to_blob_id(
								name,
								await object.async('arrayBuffer')
							);
					}
					let path =
						'/' +
						(await save_file_to_blob_id(
							`${app_name}.json`,
							JSON.stringify(app_object)
						)) +
						'/';
					console.log('Redirecting to:', path);
					window.location.pathname = path;
				}
			}
		} catch (e) {
			alert(e.toString());
		}
	};
}

/**
 * Prettify the current source file.
 */
async function sourcePretty() {
	let prettier = (await import('/prettier/standalone.mjs')).default;
	let babel = (await import('/prettier/babel.mjs')).default;
	let estree = (await import('/prettier/estree.mjs')).default;
	let prettier_html = (await import('/prettier/html.mjs')).default;
	let source = gEditor.state.doc.toString();
	let formatted = await prettier.format(source, {
		parser: gCurrentFile?.toLowerCase()?.endsWith('.html') ? 'html' : 'babel',
		plugins: [babel, estree, prettier_html],
		trailingComma: 'es5',
		useTabs: true,
		semi: true,
		singleQuote: true,
		bracketSpacing: false,
	});
	if (source !== formatted) {
		gEditor.dispatch({
			changes: {
				from: 0,
				to: gEditor.state.doc.length,
				insert: formatted,
			},
		});
	}
}

/**
 * Toggle visible whitespace.
 */
function toggleVisibleWhitespace() {
	let editor_style = document.getElementById('editor_style');
	/*
	 * There is likely a better way to do this, but stomping on the CSS was
	 * the easiest to wrangle at the time.
	 */
	if (editor_style.innerHTML.length) {
		editor_style.innerHTML = '';
		window.localStorage.setItem('visible_whitespace', '0');
	} else {
		editor_style.innerHTML = css`
			.cm-trailingSpace {
				background-color: unset !important;
			}
			.cm-highlightTab {
				background-image: unset !important;
			}
			.cm-highlightSpace {
				background-image: unset !important;
			}
		`;
		window.localStorage.setItem('visible_whitespace', '1');
	}
}

/**
 * Register event handlers and connect the WebSocket on load.
 */
window.addEventListener('load', function () {
	window.addEventListener('hashchange', hashChange);
	window.addEventListener('focus', focus);
	window.addEventListener('blur', blur);
	window.addEventListener('message', message, false);
	window.addEventListener('online', connectSocket);
	window.addEventListener('beforeunload', function () {
		gUnloading = true;
	});
	document.getElementById('name').value = window.location.pathname;
	document
		.getElementById('closeEditor')
		.addEventListener('click', () => closeEditor());
	document.getElementById('save').addEventListener('click', () => save());
	document.getElementById('icon').addEventListener('click', () => changeIcon());
	document
		.getElementById('delete')
		.addEventListener('click', () => deleteApp());
	document
		.getElementById('export')
		.addEventListener('click', () => appExport());
	document
		.getElementById('import')
		.addEventListener('click', () => appImport());
	document
		.getElementById('pretty')
		.addEventListener('click', () => sourcePretty());
	document
		.getElementById('whitespace')
		.addEventListener('click', () => toggleVisibleWhitespace());
	document
		.getElementById('trace_button')
		.addEventListener('click', function (event) {
			event.preventDefault();
			trace();
		});
	connectSocket(window.location.pathname);

	if (window.localStorage.getItem('editing') == '1') {
		edit();
	} else {
		closeEditor();
	}
	if (window.localStorage.getItem('visible_whitespace') == '1') {
		toggleVisibleWhitespace();
	}
});

/** @} */
