/**
 * \file
 * \defgroup tfcore Tilde Friends Core JS
 * Tilde Friends process management, in JavaScript.
 * @{
 */

/** \cond */
import * as app from './app.js';
import * as http from './http.js';

export {invoke, getProcessBlob};
/** \endcond */

/** All running processes. */
let gProcesses = {};
/** Whether stats are currently being sent. */
let gStatsTimer = false;
/** Effectively a process ID. */
let g_handler_index = 0;
/** Time between pings, in milliseconds. */
const k_ping_interval = 60 * 1000;

/**
 * Print an error.
 * @param error The error.
 */
function printError(error) {
	if (error.stackTrace) {
		print(error.fileName + ':' + error.lineNumber + ': ' + error.message);
		print(error.stackTrace);
	} else {
		for (let [k, v] of Object.entries(error)) {
			print(k, v);
		}
		print(error.toString());
	}
}

/**
 * Invoke a handler.
 * @param handlers The handlers on which to invoke the callback.
 * @param argv Arguments to pass to the handlers.
 * @return A promise.
 */
function invoke(handlers, argv) {
	let promises = [];
	if (handlers) {
		for (let i = 0; i < handlers.length; ++i) {
			try {
				promises.push(handlers[i](...argv));
			} catch (error) {
				handlers.splice(i, 1);
				i--;
				promises.push(
					new Promise(function (resolve, reject) {
						reject(error);
					})
				);
			}
		}
	}
	return Promise.all(promises);
}

/**
 * Broadcast a named event to all registered apps.
 * @param eventName the name of the event.
 * @param argv Arguments to pass to the handlers.
 * @return A promise.
 */
function broadcastEvent(eventName, argv) {
	let promises = [];
	for (let process of Object.values(gProcesses)) {
		if (process.eventHandlers[eventName]) {
			promises.push(invoke(process.eventHandlers[eventName], argv));
		}
	}
	return Promise.all(promises);
}

/**
 * Send a message to all other instances of the same app.
 * @param message The message.
 * @return A promise.
 */
function broadcast(message) {
	let sender = this;
	let promises = [];
	for (let process of Object.values(gProcesses)) {
		if (
			process != sender &&
			process.packageOwner == sender.packageOwner &&
			process.packageName == sender.packageName
		) {
			let from = getUser(process, sender);
			promises.push(postMessageInternal(from, process, message));
		}
	}
	return Promise.all(promises);
}

/**
 * Send a message to all instances of the same app running as the same user.
 * @param user The user.
 * @param packageOwner The owner of the app.
 * @param packageName The name of the app.
 * @param eventName The name of the event.
 * @param argv The arguments to pass.
 * @return A promise.
 */
function broadcastAppEventToUser(
	user,
	packageOwner,
	packageName,
	eventName,
	argv
) {
	let promises = [];
	for (let process of Object.values(gProcesses)) {
		if (
			process.credentials?.session?.name === user &&
			process.packageOwner == packageOwner &&
			process.packageName == packageName
		) {
			if (process.eventHandlers[eventName]) {
				promises.push(invoke(process.eventHandlers[eventName], argv));
			}
		}
	}
	return Promise.all(promises);
}

/**
 * Get user context information for a call.
 * @param caller The calling process.
 * @param process The receiving process.
 */
function getUser(caller, process) {
	return {
		key: process.key,
		packageOwner: process.packageOwner,
		packageName: process.packageName,
		credentials: process.credentials,
		postMessage: postMessageInternal.bind(caller, caller, process),
	};
}

/**
 * Send a message.
 * @param from The calling process.
 * @param to The receiving process.
 * @param message The message.
 * @return A promise.
 */
function postMessageInternal(from, to, message) {
	if (to.eventHandlers['message']) {
		return invoke(to.eventHandlers['message'], [getUser(from, from), message]);
	}
}

/**
 * Get or create a process for an app blob.
 * @param blobId The blob identifier.
 * @param key A unique key for the invocation.
 * @param options Other options.
 * @return The process.
 */
async function getProcessBlob(blobId, key, options) {
	let process = gProcesses[key];
	if (!process && !(options && 'create' in options && !options.create)) {
		let resolveReady;
		let rejectReady;
		try {
			print('Creating task for ' + blobId + ' ' + key);
			process = {};
			process.key = key;
			process.credentials = options.credentials || {};
			process.task = new Task();
			process.packageOwner = options.packageOwner;
			process.packageName = options.packageName;
			process.eventHandlers = {};
			if (!options?.script || options?.script === 'app.js') {
				process.app = new app.App();
			}
			process.lastActive = Date.now();
			process.lastPing = null;
			process.timeout = k_ping_interval;
			process.ready = new Promise(function (resolve, reject) {
				resolveReady = resolve;
				rejectReady = reject;
			});
			gProcesses[key] = process;
			process.task.onExit = function (exitCode, terminationSignal) {
				broadcastEvent('onSessionEnd', [getUser(process, process)]);
				process.task = null;
				delete gProcesses[key];
			};
			let imports = {
				core: {
					broadcast: broadcast.bind(process),
					user: getUser(process, process),
					users: async function () {
						try {
							return JSON.parse(await new Database('auth').get('users'));
						} catch {
							return [];
						}
					},
					permissionsGranted: async function () {
						let user = process?.credentials?.session?.name;
						let settings = await loadSettings();
						if (
							user &&
							options?.packageOwner &&
							options?.packageName &&
							settings.userPermissions &&
							settings.userPermissions[user] &&
							settings.userPermissions[user][options.packageOwner]
						) {
							return settings.userPermissions[user][options.packageOwner][
								options.packageName
							];
						}
					},
					allPermissionsGranted: async function () {
						let user = process?.credentials?.session?.name;
						let settings = await loadSettings();
						if (
							user &&
							options?.packageOwner &&
							options?.packageName &&
							settings.userPermissions &&
							settings.userPermissions[user]
						) {
							return settings.userPermissions[user];
						}
					},
					permissionsForUser: async function (user) {
						let settings = await loadSettings();
						return settings?.permissions?.[user] ?? [];
					},
					getSockets: getSockets,
					permissionTest: async function (permission) {
						let user = process?.credentials?.session?.name;
						let settings = await loadSettings();
						if (!user || !options?.packageOwner || !options?.packageName) {
							return;
						} else if (
							settings.userPermissions &&
							settings.userPermissions[user] &&
							settings.userPermissions[user][options.packageOwner] &&
							settings.userPermissions[user][options.packageOwner][
								options.packageName
							] &&
							settings.userPermissions[user][options.packageOwner][
								options.packageName
							][permission] !== undefined
						) {
							if (
								settings.userPermissions[user][options.packageOwner][
									options.packageName
								][permission]
							) {
								return true;
							} else {
								throw Error(`Permission denied: ${permission}.`);
							}
						} else if (process.app) {
							return process.app
								.makeFunction(['requestPermission'])(permission)
								.then(async function (value) {
									if (value == 'allow') {
										await ssb.setUserPermission(
											user,
											options.packageOwner,
											options.packageName,
											permission,
											true
										);
										process.sendPermissions();
										return true;
									} else if (value == 'allow once') {
										return true;
									} else if (value == 'deny') {
										await ssb.setUserPermission(
											user,
											options.packageOwner,
											options.packageName,
											permission,
											false
										);
										process.sendPermissions();
										throw Error(`Permission denied: ${permission}.`);
									} else if (value == 'deny once') {
										throw Error(`Permission denied: ${permission}.`);
									}
									throw Error(`Permission denied: ${permission}.`);
								});
						} else {
							throw Error(`Permission denied: ${permission}.`);
						}
					},
					app: {
						owner: options?.packageOwner,
						name: options?.packageName,
					},
					url: options?.url,
				},
			};
			process.sendIdentities = async function () {
				process.app.send(
					Object.assign(
						{
							action: 'identities',
						},
						await ssb.getIdentityInfo(
							process?.credentials?.session?.name,
							options?.packageOwner,
							options?.packageName
						)
					)
				);
			};
			process.setActiveIdentity = async function (identity) {
				if (
					process?.credentials?.session?.name &&
					options.packageOwner &&
					options.packageName
				) {
					await new Database(process?.credentials?.session?.name).set(
						`id:${options.packageOwner}:${options.packageName}`,
						identity
					);
				}
				process.sendIdentities();
				broadcastAppEventToUser(
					process?.credentials?.session?.name,
					options.packageOwner,
					options.packageName,
					'setActiveIdentity',
					[identity]
				);
			};
			process.createIdentity = async function () {
				if (
					process.credentials &&
					process.credentials.session &&
					process.credentials.session.name &&
					process.credentials.session.name !== 'guest'
				) {
					let id = await ssb.createIdentity(process.credentials.session.name);
					await process.sendIdentities();
					broadcastAppEventToUser(
						process?.credentials?.session?.name,
						options.packageOwner,
						options.packageName,
						'setActiveIdentity',
						[
							await ssb.getActiveIdentity(
								process.credentials?.session?.name,
								options.packageOwner,
								options.packageName
							),
						]
					);
					return id;
				} else {
					throw new Error('Must be signed-in to create an account.');
				}
			};
			if (process.credentials?.permissions?.administration) {
				imports.core.globalSettingsDescriptions = async function () {
					let settings = Object.assign({}, defaultGlobalSettings());
					for (let [key, value] of Object.entries(await loadSettings())) {
						if (settings[key]) {
							settings[key].value = value;
						}
					}
					return settings;
				};
				imports.core.globalSettingsGet = async function (key) {
					let settings = await loadSettings();
					return settings?.[key];
				};
				imports.core.globalSettingsSet = async function (key, value) {
					await imports.core.permissionTest('set_global_setting');
					print('Setting', key, value);
					let settings = await loadSettings();
					settings[key] = value;
					await new Database('core').set('settings', JSON.stringify(settings));
					print('Done.');
				};
				imports.core.deleteUser = async function (user) {
					await imports.core.permissionTest('delete_user');
					let db = new Database('auth');
					db.remove('user:' + user);
					let users = new Set();
					let users_original = await db.get('users');
					try {
						users = new Set(JSON.parse(users_original));
					} catch {}
					users.delete(user);
					users = JSON.stringify([...users].sort());
					if (users !== users_original) {
						await db.set('users', users);
					}
				};
			}
			if (options.api) {
				imports.app = {};
				for (let i in options.api) {
					let api = options.api[i];
					imports.app[api[0]] = process.app.makeFunction(api);
				}
			}
			for (let [name, f] of Object.entries(options?.imports || {})) {
				imports[name] = f;
			}
			process.task.onPrint = function (args) {
				if (imports.app) {
					imports.app.print(...args);
				}
			};
			process.task.onError = function (error) {
				try {
					if (process.app) {
						process.app.makeFunction(['error'])(error);
					} else {
						printError(error);
					}
				} catch (e) {
					printError(error);
				}
			};
			imports.ssb = Object.fromEntries(
				Object.keys(ssb).map((key) => [key, ssb[key].bind(ssb)])
			);
			imports.ssb.createIdentity = () => process.createIdentity();
			imports.ssb.addIdentity = function (id) {
				if (
					process.credentials &&
					process.credentials.session &&
					process.credentials.session.name
				) {
					return Promise.resolve(
						imports.core.permissionTest('ssb_id_add')
					).then(function () {
						return ssb.addIdentity(process.credentials.session.name, id);
					});
				}
			};
			imports.ssb.deleteIdentity = function (id) {
				if (
					process.credentials &&
					process.credentials.session &&
					process.credentials.session.name
				) {
					return Promise.resolve(
						imports.core.permissionTest('ssb_id_delete')
					).then(function () {
						return ssb.deleteIdentity(process.credentials.session.name, id);
					});
				}
			};
			imports.ssb.setActiveIdentity = (id) => process.setActiveIdentity(id);
			imports.ssb.getActiveIdentity = () =>
				ssb.getActiveIdentity(
					process.credentials?.session?.name,
					options.packageOwner,
					options.packageName
				);
			imports.ssb.getOwnerIdentities = function () {
				if (options.packageOwner) {
					return ssb.getIdentities(options.packageOwner);
				}
			};
			imports.ssb.getIdentities = function () {
				if (
					process.credentials &&
					process.credentials.session &&
					process.credentials.session.name
				) {
					return ssb.getIdentities(process.credentials.session.name);
				}
			};
			imports.ssb.getPrivateKey = function (id) {
				if (
					process.credentials &&
					process.credentials.session &&
					process.credentials.session.name
				) {
					return Promise.resolve(
						imports.core.permissionTest('ssb_id_export')
					).then(function () {
						return ssb.getPrivateKey(process.credentials.session.name, id);
					});
				}
			};
			imports.ssb.appendMessageWithIdentity = function (id, message) {
				if (
					process.credentials &&
					process.credentials.session &&
					process.credentials.session.name
				) {
					return Promise.resolve(
						imports.core.permissionTest('ssb_append')
					).then(function () {
						return ssb.appendMessageWithIdentity(
							process.credentials.session.name,
							id,
							message
						);
					});
				}
			};
			imports.ssb.privateMessageEncrypt = function (id, recipients, message) {
				if (
					process.credentials &&
					process.credentials.session &&
					process.credentials.session.name
				) {
					return ssb.privateMessageEncrypt(
						process.credentials.session.name,
						id,
						recipients,
						message
					);
				}
			};
			imports.ssb.privateMessageDecrypt = function (id, message) {
				if (
					process.credentials &&
					process.credentials.session &&
					process.credentials.session.name
				) {
					return ssb.privateMessageDecrypt(
						process.credentials.session.name,
						id,
						message
					);
				}
			};
			imports.ssb.swapWithServerIdentity = function (id) {
				if (
					process.credentials &&
					process.credentials.session &&
					process.credentials.session.name
				) {
					return ssb.swapWithServerIdentity(
						process.credentials.session.name,
						id
					);
				}
			};
			imports.ssb.addEventListener = undefined;
			imports.ssb.removeEventListener = undefined;
			imports.ssb.getIdentityInfo = undefined;
			imports.fetch = async function (url, options) {
				let settings = await loadSettings();
				return http.fetch(url, options, settings?.fetch_hosts);
			};

			if (
				process.credentials &&
				process.credentials.session &&
				process.credentials.session.name
			) {
				imports.database = function (key) {
					let db = new Database(process.credentials.session.name + ':' + key);
					return Object.fromEntries(
						Object.keys(db).map((x) => [x, db[x].bind(db)])
					);
				};
				imports.my_shared_database = function (packageName, key) {
					let db = new Database(
						':shared:' +
							process.credentials.session.name +
							':' +
							packageName +
							':' +
							key
					);
					return Object.fromEntries(
						Object.keys(db).map((x) => [x, db[x].bind(db)])
					);
				};
				imports.databases = async function () {
					return [].concat(
						await databases.list(
							':shared:' + process.credentials.session.name + ':%'
						),
						await databases.list(process.credentials.session.name + ':%')
					);
				};
			}
			if (options.packageOwner && options.packageName) {
				imports.shared_database = function (key) {
					let db = new Database(
						':shared:' +
							options.packageOwner +
							':' +
							options.packageName +
							':' +
							key
					);
					return Object.fromEntries(
						Object.keys(db).map((x) => [x, db[x].bind(db)])
					);
				};
			}
			process.sendPermissions = async function sendPermissions() {
				process.app.send({
					action: 'permissions',
					permissions: await imports.core.permissionsGranted(),
				});
			};
			process.client_api = {
				createIdentity: function () {
					return process.createIdentity();
				},
				resetPermission: async function resetPermission(message) {
					let user = process?.credentials?.session?.name;
					await ssb.setUserPermission(
						user,
						options?.packageOwner,
						options?.packageName,
						message.permission,
						undefined
					);
					return process.sendPermissions();
				},
				setActiveIdentity: function setActiveIdentity(message) {
					return process.setActiveIdentity(message.identity);
				},
			};
			ssb.registerImports(imports, process);
			process.task.setImports(imports);
			process.task.activate();
			let source = await ssb.blobGet(blobId);
			let appSourceName = blobId;
			let appSource = utf8Decode(source);
			try {
				let appObject = JSON.parse(appSource);
				if (appObject.type == 'tildefriends-app') {
					appSourceName = options?.script ?? 'app.js';
					let id = appObject.files[appSourceName];
					let blob = await ssb.blobGet(id);
					appSource = utf8Decode(blob);
					await process.task.loadFile([
						'/tfrpc.js',
						await File.readFile('core/tfrpc.js'),
					]);
					await Promise.all(
						Object.keys(appObject.files).map(async function (f) {
							await process.task.loadFile([
								f,
								await ssb.blobGet(appObject.files[f]),
							]);
						})
					);
				}
			} catch (e) {
				printError(e);
			}
			broadcastEvent('onSessionBegin', [getUser(process, process)]);
			if (process.app) {
				process.app.send({action: 'ready', version: version()});
				await process.sendPermissions();
			}
			await process.task.execute({name: appSourceName, source: appSource});
			resolveReady(process);
			if (!gStatsTimer) {
				gStatsTimer = true;
				sendStats();
			}
		} catch (error) {
			if (process?.app && process?.task?.onError) {
				process.task.onError(error);
			} else {
				printError(error);
			}
			rejectReady(error);
		}
	}
	return process;
}

/**
 * SSB message added callback.
 */
ssb.addEventListener('message', function () {
	broadcastEvent('onMessage', [...arguments]);
});

ssb.addEventListener('blob', function () {
	broadcastEvent('onBlob', [...arguments]);
});

ssb.addEventListener('broadcasts', function () {
	broadcastEvent('onBroadcastsChanged', []);
});

ssb.addEventListener('connections', function () {
	broadcastEvent('onConnectionsChanged', []);
});

/**
 * Load settings from the database.
 * @return The settings as a key value pairs object.
 */
async function loadSettings() {
	let data = {};
	try {
		let settings = await new Database('core').get('settings');
		if (settings) {
			data = JSON.parse(settings);
		}
	} catch (error) {
		print('Settings not found in database:', error);
	}
	for (let [key, value] of Object.entries(defaultGlobalSettings())) {
		if (data[key] === undefined) {
			data[key] = value.default_value;
		}
	}
	return data;
}

/**
 * Send periodic stats to all clients.
 */
function sendStats() {
	let apps = Object.values(gProcesses)
		.filter((process) => process.app)
		.map((process) => process.app);
	if (apps.length) {
		let stats = getStats();
		for (let app of apps) {
			app.send({action: 'stats', stats: stats});
		}
		setTimeout(sendStats, 1000);
	} else {
		gStatsTimer = false;
	}
}

/**
 * Invoke an app's handler.js.
 * @param response The response object.
 * @param app_blob_id The app's blob identifier.
 * @param path The request path.
 * @param query The request query string.
 * @param headers The request headers.
 * @param package_owner The app's owner.
 * @param package_name The app's name.
 */
exports.callAppHandler = async function callAppHandler(
	response,
	app_blob_id,
	path,
	query,
	headers,
	package_owner,
	package_name
) {
	let answer;
	try {
		let do_resolve;
		let promise = new Promise(async function (resolve, reject) {
			do_resolve = resolve;
		});
		let process;
		try {
			process = await getProcessBlob(
				app_blob_id,
				'handler_' + g_handler_index++,
				{
					script: 'handler.js',
					imports: {
						request: {
							path: path,
							query: query,
						},
						respond: do_resolve,
					},
					credentials: await httpd.auth_query(headers),
					packageOwner: package_owner,
					packageName: package_name,
				}
			);
			await process.ready;
			answer = await promise;
		} finally {
			if (process?.task) {
				await process.task.kill();
			}
		}
	} catch (error) {
		let data = utf8Encode(
			`Internal Server Error\n\n${error?.message}\n${error?.stack}`
		);
		response.writeHead(500, {
			'Content-Type': 'text/plain; charset=utf-8',
			'Content-Length': data.length,
		});
		response.end(data);
		return;
	}
	if (typeof answer?.data == 'string') {
		answer.data = utf8Encode(answer.data);
	}
	response.writeHead(answer?.status_code, {
		'Content-Type': answer?.content_type,
		'Content-Length': answer?.data?.length,
		'Access-Control-Allow-Origin': '*',
		'Content-Security-Policy':
			'sandbox allow-downloads allow-top-navigation-by-user-activation',
	});
	response.end(answer?.data);
};

/** @} */
