import * as app from './app.js';
import * as form from './form.js';
import * as http from './http.js';

let gProcesses = {};
let gStatsTimer = false;

const k_content_security_policy =
	'sandbox allow-downloads allow-top-navigation-by-user-activation';

let k_static_files = [
	{uri: '/', path: 'index.html', type: 'text/html; charset=UTF-8'},
];

const k_global_settings = {
	index: {
		type: 'string',
		default_value: '/~core/apps/',
		description: 'Default path.',
	},
	index_map: {
		type: 'textarea',
		default_value: undefined,
		description:
			'Mappings from hostname to redirect path, one per line, as in: "www.tildefriends.net=/~core/index/"',
	},
	room: {
		type: 'boolean',
		default_value: true,
		description: 'Whether this instance should behave as a room.',
	},
	room_name: {
		type: 'string',
		default_value: 'tilde friends tunnel',
		description: 'Name of the room.',
	},
	code_of_conduct: {
		type: 'textarea',
		default_value: undefined,
		description: 'Code of conduct presented at sign-in.',
	},
	http_redirect: {
		type: 'string',
		default_value: undefined,
		description:
			'If connecting by HTTP and HTTPS is configured, Location header prefix (ie, "https://example.com")',
	},
	fetch_hosts: {
		type: 'string',
		default_value: undefined,
		description:
			'Comma-separated list of host names to which HTTP fetch requests are allowed.  None if empty.',
	},
	blob_fetch_age_seconds: {
		type: 'integer',
		default_value:
			platform() == 'android' || platform() == 'iphone'
				? 0.5 * 365 * 24 * 60 * 60
				: undefined,
		description:
			'Only blobs mentioned more recently than this age will be automatically fetched.',
	},
	blob_expire_age_seconds: {
		type: 'integer',
		default_value:
			platform() == 'android' || platform() == 'iphone'
				? 1.0 * 365 * 24 * 60 * 60
				: undefined,
		description: 'Blobs older than this will be automatically deleted.',
	},
};

let gGlobalSettings = {
	index: '/~core/apps/',
};

let kPingInterval = 60 * 1000;

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

/**
 * TODOC
 * @param {*} handlers
 * @param {*} argv
 * @returns
 */
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);
}

/**
 * TODOC
 * @param {*} eventName
 * @param {*} argv
 * @returns
 */
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);
}

/**
 * TODOC
 * @param {*} message
 * @returns
 */
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);
}

/**
 * TODOC
 * @param {String} eventName
 * @param {*} argv
 * @returns
 */
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);
}

/**
 * TODOC
 * @param {*} caller
 * @param {*} process
 * @returns
 */
function getUser(caller, process) {
	return {
		key: process.key,
		packageOwner: process.packageOwner,
		packageName: process.packageName,
		credentials: process.credentials,
		postMessage: postMessageInternal.bind(caller, caller, process),
	};
}

/**
 * TODOC
 * @param {*} user
 * @param {*} process
 * @returns
 */
async function getApps(user, process) {
	if (
		process.credentials &&
		process.credentials.session &&
		process.credentials.session.name
	) {
		if (user && user !== process.credentials.session.name && user !== 'core') {
			return {};
		} else if (!user) {
			user = process.credentials.session.name;
		}
	}
	if (user) {
		let db = new Database(user);
		try {
			let names = JSON.parse(await db.get('apps'));
			let result = {};
			for (let name of names) {
				result[name] = await db.get('path:' + name);
			}
			return result;
		} catch {}
	}
	return {};
}

/**
 * TODOC
 * @param {*} from
 * @param {*} to
 * @param {*} message
 * @returns
 */
function postMessageInternal(from, to, message) {
	if (to.eventHandlers['message']) {
		return invoke(to.eventHandlers['message'], [getUser(from, from), message]);
	}
}

/**
 * TODOC
 * @param {*} blobId
 * @param {*} session
 * @param {*} options
 * @returns
 */
async function getSessionProcessBlob(blobId, session, options) {
	let actualOptions = {timeout: kPingInterval};
	if (options) {
		for (let i in options) {
			actualOptions[i] = options[i];
		}
	}
	return getProcessBlob(blobId, 'session_' + session, actualOptions);
}

/**
 * TODOC
 * @param {*} blobId
 * @param {*} key
 * @param {*} options
 * @returns
 */
async function getProcessBlob(blobId, key, options) {
	// TODO(tasiaiso): break this down ?
	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 = options.timeout;
			process.stats = false;
			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),
					register: function (eventName, handler) {
						if (!process.eventHandlers[eventName]) {
							process.eventHandlers[eventName] = [];
						}
						process.eventHandlers[eventName].push(handler);
					},
					unregister: function (eventName, handler) {
						if (process.eventHandlers[eventName]) {
							let index = process.eventHandlers[eventName].indexOf(handler);
							if (index != -1) {
								process.eventHandlers[eventName].splice(index, 1);
							}
							if (process.eventHandlers[eventName].length == 0) {
								delete process.eventHandlers[eventName];
							}
						}
					},
					user: getUser(process, process),
					users: async function () {
						try {
							return JSON.parse(await new Database('auth').get('users'));
						} catch {
							return [];
						}
					},
					permissionsGranted: function () {
						let user = process?.credentials?.session?.name;
						if (
							user &&
							options?.packageOwner &&
							options?.packageName &&
							gGlobalSettings.userPermissions &&
							gGlobalSettings.userPermissions[user] &&
							gGlobalSettings.userPermissions[user][options.packageOwner]
						) {
							return gGlobalSettings.userPermissions[user][
								options.packageOwner
							][options.packageName];
						}
					},
					allPermissionsGranted: function () {
						let user = process?.credentials?.session?.name;
						if (
							user &&
							options?.packageOwner &&
							options?.packageName &&
							gGlobalSettings.userPermissions &&
							gGlobalSettings.userPermissions[user]
						) {
							return gGlobalSettings.userPermissions[user];
						}
					},
					permissionsForUser: function (user) {
						return (
							(gGlobalSettings?.permissions
								? gGlobalSettings.permissions[user]
								: []) ?? []
						);
					},
					apps: (user) => getApps(user, process),
					getSockets: getSockets,
					permissionTest: function (permission) {
						let user = process?.credentials?.session?.name;
						if (!user || !options?.packageOwner || !options?.packageName) {
							return;
						} else if (
							gGlobalSettings.userPermissions &&
							gGlobalSettings.userPermissions[user] &&
							gGlobalSettings.userPermissions[user][options.packageOwner] &&
							gGlobalSettings.userPermissions[user][options.packageOwner][
								options.packageName
							] &&
							gGlobalSettings.userPermissions[user][options.packageOwner][
								options.packageName
							][permission] !== undefined
						) {
							if (
								gGlobalSettings.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(function (value) {
									if (value == 'allow') {
										storePermission(
											user,
											options.packageOwner,
											options.packageName,
											permission,
											true
										);
										process.sendPermissions();
										return true;
									} else if (value == 'allow once') {
										return true;
									} else if (value == 'deny') {
										storePermission(
											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 = function () {
					let settings = Object.assign({}, k_global_settings);
					for (let [key, value] of Object.entries(gGlobalSettings)) {
						if (settings[key]) {
							settings[key].value = value;
						}
					}
					return settings;
				};
				imports.core.globalSettingsGet = function (key) {
					return gGlobalSettings[key];
				};
				imports.core.globalSettingsSet = function (key, value) {
					print('Setting', key, value);
					gGlobalSettings[key] = value;
					setGlobalSettings(gGlobalSettings);
					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({print: print}, error);
					}
				} catch (e) {
					printError({print: print}, error);
				}
			};
			imports.ssb = Object.fromEntries(
				Object.keys(ssb).map((key) => [key, ssb[key].bind(ssb)])
			);
			imports.ssb.port = tildefriends.ssb_port;
			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.setServerFollowingMe = function (id, following) {
				if (
					process.credentials &&
					process.credentials.session &&
					process.credentials.session.name
				) {
					return ssb.setServerFollowingMe(
						process.credentials.session.name,
						id,
						following
					);
				}
			};
			imports.ssb.addEventListener = undefined;
			imports.ssb.removeEventListener = undefined;
			imports.ssb.getIdentityInfo = undefined;
			imports.fetch = function (url, options) {
				return http.fetch(url, options, gGlobalSettings.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 = function () {
					return [].concat(
						databases.list(
							':shared:' + process.credentials.session.name + ':%'
						),
						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 = function sendPermissions() {
				process.app.send({
					action: 'permissions',
					permissions: imports.core.permissionsGranted(),
				});
			};
			process.resetPermission = function resetPermission(permission) {
				let user = process?.credentials?.session?.name;
				storePermission(
					user,
					options?.packageOwner,
					options?.packageName,
					permission,
					undefined
				);
				process.sendPermissions();
			};
			process.task.setImports(imports);
			process.task.activate();
			let source = await getBlobOrContent(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 getBlobOrContent(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 getBlobOrContent(appObject.files[f]),
							]);
						})
					);
				}
			} catch (e) {
				printError({print: print}, e);
			}
			broadcastEvent('onSessionBegin', [getUser(process, process)]);
			if (process.app) {
				process.app.send({action: 'ready', version: version()});
				process.sendPermissions();
			}
			await process.task.execute({name: appSourceName, source: appSource});
			resolveReady(process);
		} catch (error) {
			if (process.app) {
				if (process?.task?.onError) {
					process.task.onError(error);
				} else {
					printError({print: print}, error);
				}
			} else {
				printError({print: print}, error);
			}
			rejectReady(error);
		}
	}
	return process;
}

/**
 * TODOC
 * @param {*} settings
 * @returns
 */
async function setGlobalSettings(settings) {
	gGlobalSettings = settings;
	try {
		return await new Database('core').set('settings', JSON.stringify(settings));
	} catch (error) {
		print('Error storing settings:', error);
	}
}

/**
 * TODOC
 * @param {*} response
 * @param {*} data
 * @param {*} type
 * @param {*} headers
 * @param {*} status_code
 */
function sendData(response, data, type, headers, status_code) {
	if (data) {
		response.writeHead(
			status_code ?? 200,
			Object.assign(
				{
					'Content-Type':
						type ||
						httpd.mime_type_from_magic_bytes(data) ||
						'application/binary',
					'Content-Length': data.byteLength,
				},
				headers || {}
			)
		);
		response.end(data);
	} else {
		response.writeHead(
			status_code ?? 404,
			Object.assign(
				{
					'Content-Type': 'text/plain; charset=utf-8',
					'Content-Length': 'File not found'.length,
				},
				headers || {}
			)
		);
		response.end('File not found');
	}
}

/**
 * TODOC
 * @param {*} id
 * @returns
 */
async function getBlobOrContent(id) {
	if (!id) {
		return;
	} else if (id.startsWith('&')) {
		return ssb.blobGet(id);
	} else if (id.startsWith('%')) {
		return ssb.messageContentGet(id);
	}
}

let g_handler_index = 0;

/**
 * TODOC
 * @param {*} response
 * @param {*} handler_blob_id
 * @param {*} path
 * @param {*} query
 * @param {*} headers
 * @param {*} packageOwner
 * @param {*} packageName
 * @returns
 */
async function useAppHandler(
	response,
	handler_blob_id,
	path,
	query,
	headers,
	packageOwner,
	packageName
) {
	print('useAppHandler', packageOwner, packageName);
	let do_resolve;
	let promise = new Promise(async function (resolve, reject) {
		do_resolve = resolve;
	});
	let process;
	let result;
	try {
		process = await getProcessBlob(
			handler_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: packageOwner,
				packageName: packageName,
			}
		);
		await process.ready;

		result = await promise;
	} finally {
		if (process?.task) {
			await process.task.kill();
		}
	}
	return result;
}

/**
 * TODOC
 * @param {*} request
 * @param {*} response
 * @param {*} blobId
 * @param {*} uri
 * @returns
 */
async function blobHandler(request, response, blobId, uri) {
	// TODO(tasiaiso): break this down ?
	for (let i in k_static_files) {
		if (uri === k_static_files[i].uri && k_static_files[i].path) {
			let stat = await File.stat('core/' + k_static_files[i].path);
			let id = `${stat.mtime}_${stat.size}`;

			if (request.headers['if-none-match'] === '"' + id + '"') {
				response.writeHead(304, {'Content-Length': '0'});
				response.end();
			} else {
				let data = await File.readFile('core/' + k_static_files[i].path);
				response.writeHead(
					200,
					Object.assign(
						{
							'Content-Type': k_static_files[i].type,
							'Content-Length': data.byteLength,
							etag: '"' + id + '"',
						},
						k_static_files[i].headers || {}
					)
				);
				response.end(data);
			}
			return;
		}
	}

	if (!uri) {
		response.writeHead(303, {
			Location:
				(request.client.tls ? 'https://' : 'http://') +
				(request.headers['x-forwarded-host'] ?? request.headers.host) +
				blobId +
				'/',
			'Content-Length': '0',
		});
		response.end();
		return;
	}

	let process;
	if (uri == '/view') {
		let data;
		let match;
		let query = form.decodeForm(request.query);
		let headers = {
			'Content-Security-Policy': k_content_security_policy,
		};
		if (query.filename && query.filename.match(/^[A-Za-z0-9\.-]*$/)) {
			headers['Content-Disposition'] = `attachment; filename=${query.filename}`;
		}
		if ((match = /^\/\~(\w+)\/(\w+)$/.exec(blobId))) {
			let id = await new Database(match[1]).get('path:' + match[2]);
			if (id) {
				if (request.headers['if-none-match'] === '"' + id + '"') {
					headers['Content-Length'] = '0';
					response.writeHead(304, headers);
					response.end();
				} else {
					data = await getBlobOrContent(id);
					if (match[3]) {
						let appObject = JSON.parse(data);
						data = appObject.files[match[3]];
					}
					sendData(
						response,
						data,
						undefined,
						Object.assign({etag: '"' + id + '"'}, headers)
					);
				}
			} else {
				if (request.headers['if-none-match'] === '"' + blobId + '"') {
					headers['Content-Length'] = '0';
					response.writeHead(304, headers);
					response.end();
				} else {
					sendData(
						response,
						data,
						undefined,
						Object.assign({etag: '"' + blobId + '"'}, headers)
					);
				}
			}
		} else {
			if (request.headers['if-none-match'] === '"' + blobId + '"') {
				headers['Content-Length'] = '0';
				response.writeHead(304, headers);
				response.end();
			} else {
				data = await getBlobOrContent(blobId);
				sendData(
					response,
					data,
					undefined,
					Object.assign({etag: '"' + blobId + '"'}, headers)
				);
			}
		}
	} else if (uri == '/save') {
		let match;
		if ((match = /^\/\~(\w+)\/(\w+)$/.exec(blobId))) {
			let user = match[1];
			let appName = match[2];
			let credentials = await httpd.auth_query(request.headers);
			if (
				credentials &&
				credentials.session &&
				(credentials.session.name == user ||
					(credentials.permissions.administration && user == 'core'))
			) {
				let database = new Database(user);

				let app_object = JSON.parse(utf8Decode(request.body));
				let previous_id = await database.get('path:' + appName);
				if (previous_id) {
					try {
						let previous_object = JSON.parse(
							utf8Decode(await ssb.blobGet(previous_id))
						);
						delete previous_object.previous;
						delete app_object.previous;
						if (JSON.stringify(previous_object) == JSON.stringify(app_object)) {
							response.writeHead(200, {
								'Content-Type': 'text/plain; charset=utf-8',
							});
							response.end('/' + previous_id);
							return;
						}
					} catch {}
				}
				app_object.previous = previous_id;
				let newBlobId = await ssb.blobStore(JSON.stringify(app_object));

				let apps = new Set();
				let apps_original = await database.get('apps');
				try {
					apps = new Set(JSON.parse(apps_original));
				} catch {}
				if (!apps.has(appName)) {
					apps.add(appName);
				}
				apps = JSON.stringify([...apps].sort());
				if (apps != apps_original) {
					await database.set('apps', apps);
				}
				await database.set('path:' + appName, newBlobId);
				response.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
				response.end('/' + newBlobId);
			} else {
				response.writeHead(401, {'Content-Type': 'text/plain; charset=utf-8'});
				response.end('401 Unauthorized');
				return;
			}
		} else if (blobId === '') {
			let newBlobId = await ssb.blobStore(request.body);
			response.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
			response.end('/' + newBlobId);
		} else {
			response.writeHead(400, {'Content-Type': 'text/plain; charset=utf-8'});
			response.end('Invalid name.');
		}
	} else if (uri == '/delete') {
		let match;
		if ((match = /^\/\~(\w+)\/(\w+)$/.exec(blobId))) {
			let user = match[1];
			let appName = match[2];
			let credentials = await httpd.auth_query(request.headers);
			if (
				credentials &&
				credentials.session &&
				(credentials.session.name == user ||
					(credentials.permissions.administration && user == 'core'))
			) {
				let database = new Database(user);
				let apps = new Set();
				try {
					apps = new Set(JSON.parse(await database.get('apps')));
				} catch {}
				if (apps.delete(appName)) {
					await database.set('apps', JSON.stringify([...apps].sort()));
				}
				database.remove('path:' + appName);
			} else {
				response.writeHead(401, {'Content-Type': 'text/plain; charset=utf-8'});
				response.end('401 Unauthorized');
				return;
			}
		}

		response.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
		response.end('OK');
	} else {
		let data;
		let match;
		let id;
		let app_id = blobId;
		let packageOwner;
		let packageName;
		if ((match = /^\/\~(\w+)\/(\w+)$/.exec(blobId))) {
			packageOwner = match[1];
			packageName = match[2];
			let db = new Database(match[1]);
			app_id = await db.get('path:' + match[2]);
		}

		let app_object = JSON.parse(utf8Decode(await getBlobOrContent(app_id)));
		id = app_object?.files[uri.substring(1)];
		if (!id && app_object?.files['handler.js']) {
			let answer;
			try {
				answer = await useAppHandler(
					response,
					app_id,
					uri.substring(1),
					request.query ? form.decodeForm(request.query) : undefined,
					request.headers,
					packageOwner,
					packageName
				);
			} catch (error) {
				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 (answer && typeof answer.data == 'string') {
				answer.data = utf8Encode(answer.data);
			}
			sendData(
				response,
				answer?.data,
				answer?.content_type,
				Object.assign(answer?.headers ?? {}, {
					'Access-Control-Allow-Origin': '*',
					'Content-Security-Policy': k_content_security_policy,
				}),
				answer.status_code
			);
		} else if (id) {
			if (
				request.headers['if-none-match'] &&
				request.headers['if-none-match'] == '"' + id + '"'
			) {
				let headers = {
					'Access-Control-Allow-Origin': '*',
					'Content-Security-Policy': k_content_security_policy,
					'Content-Length': '0',
				};
				response.writeHead(304, headers);
				response.end();
			} else {
				let headers = {
					ETag: '"' + id + '"',
					'Access-Control-Allow-Origin': '*',
					'Content-Security-Policy': k_content_security_policy,
				};
				data = await getBlobOrContent(id);
				let type =
					httpd.mime_type_from_extension(uri) ||
					httpd.mime_type_from_magic_bytes(data);
				sendData(response, data, type, headers);
			}
		} else {
			sendData(response, data, undefined, {});
		}
	}
}

ssb.addEventListener('message', function () {
	broadcastEvent('onMessage', [...arguments]);
});

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

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

/**
 * TODOC
 */
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(k_global_settings)) {
		if (data[key] === undefined) {
			data[key] = value.default_value;
		}
	}
	gGlobalSettings = data;
}

/**
 * TODOC
 */
function sendStats() {
	let apps = Object.values(gProcesses)
		.filter((process) => process.app && process.stats)
		.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;
	}
}

/**
 * TODOC
 * @param {*} process
 * @param {*} enabled
 */
function enableStats(process, enabled) {
	process.stats = enabled;
	if (!gStatsTimer) {
		gStatsTimer = true;
		sendStats();
	}
}

/**
 * TODOC
 */
loadSettings()
	.then(function () {
		if (tildefriends.https_port && gGlobalSettings.http_redirect) {
			httpd.set_http_redirect(gGlobalSettings.http_redirect);
		}
		httpd.all('/app/socket', app.socket);
		httpd.all('', function default_http_handler(request, response) {
			let match;
			if ((match = /^(\/~[^\/]+\/[^\/]+)(\/?.*)$/.exec(request.uri))) {
				return blobHandler(request, response, match[1], match[2]);
			} else if (
				(match = /^\/([&\%][^\.]{44}(?:\.\w+)?)(\/?.*)/.exec(request.uri))
			) {
				return blobHandler(request, response, match[1], match[2]);
			} else if ((match = /^(.*)(\/(?:save|delete)?)$/.exec(request.uri))) {
				return blobHandler(request, response, match[1], match[2]);
			} else {
				let data = 'File not found.';
				response.writeHead(404, {
					'Content-Type': 'text/plain; charset=utf-8',
					'Content-Length': data.length.toString(),
				});
				return response.end(data);
			}
		});
		let port = httpd.start(tildefriends.http_port);
		if (tildefriends.args.out_http_port_file) {
			print('Writing the port file.');
			File.writeFile(
				tildefriends.args.out_http_port_file,
				port.toString() + '\n'
			)
				.then(function (r) {
					print(
						'Wrote the port file:',
						tildefriends.args.out_http_port_file,
						r
					);
				})
				.catch(function () {
					print('Failed to write the port file.');
				});
		}

		if (tildefriends.https_port) {
			async function start_tls() {
				const kCertificatePath = 'data/httpd/certificate.pem';
				const kPrivateKeyPath = 'data/httpd/privatekey.pem';
				let privateKey;
				let certificate;
				try {
					privateKey = utf8Decode(await File.readFile(kPrivateKeyPath));
					certificate = utf8Decode(await File.readFile(kCertificatePath));
				} catch (e) {
					print(`TLS disabled (${e.message}).`);
					return;
				}
				let context = new TlsContext();
				context.setPrivateKey(privateKey);
				context.setCertificate(certificate);
				httpd.start(tildefriends.https_port, context);
			}
			start_tls();
		}
	})
	.catch(function (error) {
		print('Failed to load settings.');
		printError({print: print}, error);
		exit(1);
	});

/**
 * TODOC
 * @param {*} user
 * @param {*} packageOwner
 * @param {*} packageName
 * @param {*} permission
 * @param {*} allow
 */
function storePermission(user, packageOwner, packageName, permission, allow) {
	if (!gGlobalSettings.userPermissions) {
		gGlobalSettings.userPermissions = {};
	}
	if (!gGlobalSettings.userPermissions[user]) {
		gGlobalSettings.userPermissions[user] = {};
	}
	if (!gGlobalSettings.userPermissions[user][packageOwner]) {
		gGlobalSettings.userPermissions[user][packageOwner] = {};
	}
	if (!gGlobalSettings.userPermissions[user][packageOwner][packageName]) {
		gGlobalSettings.userPermissions[user][packageOwner][packageName] = {};
	}
	if (
		gGlobalSettings.userPermissions[user][packageOwner][packageName][
			permission
		] !== allow
	) {
		if (allow === undefined) {
			delete gGlobalSettings.userPermissions[user][packageOwner][packageName][
				permission
			];
		} else {
			gGlobalSettings.userPermissions[user][packageOwner][packageName][
				permission
			] = allow;
		}
		setGlobalSettings(gGlobalSettings);
	}
}

export {
	gGlobalSettings as globalSettings,
	setGlobalSettings,
	enableStats,
	invoke,
	getSessionProcessBlob,
};