/**
 * Private message handling, particularly for offline messages.
 * By Mia.
 * @author mia-pi-git
 */
import { SQL, Utils } from '../../lib';
import * as ConfigLoader from '../config-loader';
import { Auth } from '../user-groups';
import { statements } from './database';
/** The time until a PM sent offline expires. Presently, 60 days. */
export const EXPIRY_TIME = 60 * 24 * 60 * 60 * 1000;
/** The time until a PM that has been seen by the user expires. Presently, one week. */
export const SEEN_EXPIRY_TIME = 7 * 24 * 60 * 60 * 1000;
/** The max PMs that one user can have pending to a specific user at one time */
export const MAX_PENDING = 20;

// this would be in database.ts, but for some weird reason, if the extension and the pm are the same
// it doesn't work. all the keys in the require() result are there, but they're also set to undefined.
// no idea why.
export const PM = SQL('private-messages', module, {
	file: 'databases/offline-pms.db',
	extension: 'server/private-messages/database.js',
});

export interface ReceivedPM {
	time: number;
	sender: string;
	receiver: string;
	seen: number | null;
	message: string;
}

export const PrivateMessages = new class {
	database = PM;
	clearInterval = this.nextClear();
	offlineIsEnabled = Config.usesqlitepms && Config.usesqlite;
	async sendOffline(to: string, from: User | string, message: string, context?: Chat.CommandContext) {
		await this.checkCanSend(to, from);
		const result = await PM.transaction('send', [toID(from), toID(to), message]);
		if (result.error) throw new Chat.ErrorMessage(result.error);
		if (typeof from === 'object') {
			from.send(`|pm|${this.getIdentity(from)}|${this.getIdentity(to)}|${message} __[sent offline]__`);
		}
		const changed = !!result.changes;
		if (changed && context) {
			Chat.runHandlers('onMessageOffline', context, message, toID(to));
		}
		return changed;
	}
	getSettings(userid: string) {
		return PM.get(statements.getSettings, [toID(userid)]);
	}
	deleteSettings(userid: string) {
		return PM.run(statements.deleteSettings, [toID(userid)]);
	}
	async checkCanSend(to: string, from: User | string) {
		from = toID(from);
		to = toID(to);
		const setting = await this.getSettings(to);
		const requirement = setting?.view_only || Config.usesqlitepms || "friends";
		switch (requirement) {
		case 'friends':
			if (!(await Chat.Friends.findFriendship(to, from))) {
				if (Config.usesqlitepms === 'friends') {
					throw new Chat.ErrorMessage(`At this time, you may only send offline PMs to friends. ${to} is not friends with you.`);
				}
				throw new Chat.ErrorMessage(`${to} is only accepting offline PMs from friends at this time.`);
			}
			break;
		case 'trusted':
			if (!Users.globalAuth.has(toID(from))) {
				throw new Chat.ErrorMessage(`${to} is currently blocking offline PMs from non-trusted users.`);
			}
			break;
		case 'none':
			// drivers+ can override
			if (!Auth.atLeast(Users.globalAuth.get(from as ID), '%')) {
				throw new Chat.ErrorMessage(`${to} has indicated that they do not wish to receive offline PMs.`);
			}
			break;
		default:
			if (!Auth.atLeast(Users.globalAuth.get(from as ID), requirement)) {
				if (setting?.view_only) {
					throw new Chat.ErrorMessage(`That user is not allowing offline PMs from your rank at this time.`);
				}
				throw new Chat.ErrorMessage('You do not meet the rank requirement to send offline PMs at this time.');
			}
			break;
		}
	}
	setViewOnly(user: User | string, val: string | null) {
		const id = toID(user);
		if (!val) { // if null, no need to save
			return PM.run(statements.deleteSettings, [id]);
		}
		return PM.run(statements.setBlock, [id, val]);
	}
	checkCanUse(user: User, options = { forceBool: false, isLogin: false }) {
		if (!this.offlineIsEnabled) {
			if (options.forceBool) return false;
			throw new Chat.ErrorMessage(`Offline PMs are currently disabled.`);
		}
		if (!(options.isLogin ? user.registered : user.autoconfirmed)) {
			if (options.forceBool) return false;
			throw new Chat.ErrorMessage("To use offline messaging you must be autoconfirmed, which means being registered for at least one week and winning one rated game.");
		}
		if (!Users.globalAuth.atLeast(user, Config.usesqlitepms)) {
			if (options.forceBool) return false;
			throw new Chat.ErrorMessage("You do not have the needed rank to send offline PMs.");
		}
		return true;
	}
	checkCanPM(user: User, pmTarget: ID) {
		this.checkCanUse(user);
		if (Config.usesqlitepms === 'friends' && !user.friends?.has(pmTarget)) {
			throw new Chat.ErrorMessage(
				`At this time, you may only send offline messages to friends. You do not have ${pmTarget} friended.`
			);
		}
	}
	async sendReceived(user: User) {
		const userid = toID(user);
		// we only want to send the unseen pms to them when they login - they can replay the rest at will otherwise
		const messages = await this.fetchUnseen(userid);
		for (const { message, time, sender } of messages) {
			user.send(
				`|pm|${this.getIdentity(sender)}|${this.getIdentity(user)}|/html ` +
				`${Utils.escapeHTML(message)} <i>[sent offline, <time>${new Date(time).toISOString()}</time>]</i>`
			);
		}
	}
	private getIdentity(user: User | string) {
		user = Users.getExact(user) || user;
		if (typeof user === 'object') {
			return user.getIdentity();
		}
		return `${Users.globalAuth.get(toID(user))}${user}`;
	}
	nextClear(): NodeJS.Timeout {
		if (!PM.isParentProcess) return null!;
		const time = Date.now();
		// even though we expire once a week atm, we check once a day
		const nextMidnight = new Date();
		nextMidnight.setHours(24, 0, 0, 0);
		if (this.clearInterval) clearTimeout(this.clearInterval);
		this.clearInterval = setTimeout(() => {
			void this.clearOffline();
			void this.clearSeen();
			this.nextClear();
		}, nextMidnight.getTime() - time);
		return this.clearInterval;
	}
	clearSeen() {
		return PM.run(statements.clearSeen, [Date.now(), SEEN_EXPIRY_TIME]);
	}
	send(message: string, user: User, pmTarget: User, onlyRecipient: User | null = null) {
		const buf = `|pm|${user.getIdentity()}|${pmTarget.getIdentity()}|${message}`;
		if (onlyRecipient) return onlyRecipient.send(buf);
		user.send(buf);
		if (pmTarget !== user) pmTarget.send(buf);
		pmTarget.lastPM = user.id;
		user.lastPM = pmTarget.id;
	}
	async fetchUnseen(user: User | string): Promise<ReceivedPM[]> {
		const userid = toID(user);
		return (await PM.transaction('listNew', [userid])) || [];
	}
	async fetchAll(user: User | string): Promise<ReceivedPM[]> {
		return (await PM.all(statements.fetch, [toID(user)])) || [];
	}
	async renderReceived(user: User) {
		const all = await this.fetchAll(user);
		let buf = `<div class="ladder pad">`;
		buf += `<h2>PMs received offline in the last ${Chat.toDurationString(SEEN_EXPIRY_TIME)}</h2>`;
		const sortedPMs: { [userid: string]: ReceivedPM[] } = {};
		for (const curPM of all) {
			if (!sortedPMs[curPM.sender]) sortedPMs[curPM.sender] = [];
			sortedPMs[curPM.sender].push(curPM);
		}
		for (const k in sortedPMs) {
			Utils.sortBy(sortedPMs[k], pm => -pm.time);
		}
		buf += `<div class="mainmenuwrapper" style="margin-left:40px">`;
		for (const pair of Utils.sortBy(Object.entries(sortedPMs), ([id]) => id)) {
			const [sender, messages] = pair;
			const group = Users.globalAuth.get(toID(sender));
			const name = Users.getExact(sender)?.name || sender;
			const id = toID(name);
			buf += Utils.html`<div class="pm-window pm-window-${id}" width="30px" data-userid="${id}" data-name="${group}${name}" style="width:300px">`;
			buf += Utils.html`<h3><small>${group}</small>${name}</h3>`;
			buf += `<div class="pm-log"><div class="pm-buttonbar">`;
			for (const { message, time } of messages) {
				buf += `<div class="chat chatmessage-${toID(sender)}">&nbsp;&nbsp;`;
				buf += `<small>[<time>${new Date(time).toISOString()}</time>] </small>`;
				buf += Utils.html`<small>${group}</small>`;
				buf += Utils.html`<span class="username" data-roomgroup="${group}" data-name="${name}"><username>${name}</username></span>: `;
				buf += `<em>${message}</em></div>`;
			}
			buf += `</div></div></div>`;
			buf += `<br />`;
		}
		buf += `</div>`;
		return buf;
	}
	clearOffline() {
		return PM.run(statements.clearDated, [Date.now(), EXPIRY_TIME]);
	}
	destroy() {
		void PM.destroy();
	}
	start(processCount: ConfigLoader.SubProcessesConfig) {
		start(processCount);
	}
};

if (!PM.isParentProcess) {
	ConfigLoader.ensureLoaded();
	global.Monitor = {
		crashlog(error: Error, source = 'A private message child process', details: AnyObject | null = null) {
			const repr = JSON.stringify([error.name, error.message, source, details]);
			process.send!(`THROW\n@!!@${repr}\n${error.stack}`);
		},
	};
	process.on('uncaughtException', err => {
		Monitor.crashlog(err, 'A private message database process');
	});
	process.on('unhandledRejection', err => {
		Monitor.crashlog(err as Error, 'A private message database process');
	});
}

function start(processCount: ConfigLoader.SubProcessesConfig) {
	if (!Config.usesqlite) {
		return;
	}
	PM.spawn(processCount['pm'] ?? 1);
	// clear super old pms on startup
	void PM.run(statements.clearDated, [Date.now(), EXPIRY_TIME]);
}
