/**
 * PS Help room auto-response plugin.
 * Uses Regex to match room frequently asked question (RFAQ) entries,
 * and replies if a match is found.
 * Supports configuration, and works in all rooms, though intended mainly for Help.
 * Written by Mia.
 * @author mia-pi-git
 */

import { FS, Utils } from '../../lib';
import { LogViewer } from './chatlog';
import { roomFaqs, visualizeFaq } from './room-faqs';

const DATA_PATH = 'config/chat-plugins/responder.json';
const LOG_PATH = Monitor.logPath('responder.jsonl').path;

export let answererData: { [roomid: string]: PluginData } = {};

try {
	answererData = JSON.parse(FS(DATA_PATH).readSync());
} catch {}

/**
 * A message caught by the filter.
 */
interface LoggedMessage {
	/** Message that's matched by the filter. */
	message: string;
	/** The FAQ that it's matched to. */
	faqName: string;
	/** The regex that it's matched to. */
	regex: string;
	date: string;
}
interface PluginData {
	/** Word pairs that have been marked as a match for a specific FAQ. */
	pairs: { [k: string]: string[] };
	/** Common terms to be ignored in question parsing. */
	ignore?: string[];
}

export class AutoResponder {
	data: PluginData;
	room: Room;
	constructor(room: Room, data?: PluginData) {
		this.room = room;
		this.data = data || { pairs: {}, ignore: [] };
		AutoResponder.migrateStats(this.data, this);
	}
	static migrateStats(data: any, responder: AutoResponder) {
		if (!data.stats) return data;
		for (const date in data.stats) {
			for (const entry of data.stats[date].matches) {
				void this.logMessage(responder.room.roomid, { ...entry, date });
			}
		}
		delete data.stats;
		responder.data = data;
		responder.writeState();
		return data;
	}
	static logStream = FS(LOG_PATH).createAppendStream();
	static logMessage(roomid: RoomID, entry: LoggedMessage) {
		return this.logStream.writeLine(JSON.stringify({
			...entry,
			room: roomid,
			regex: entry.regex.toString(),
		}));
	}
	find(question: string, user?: User) {
		// sanity slice, APPARENTLY people are dumb.
		question = question.slice(0, 300);
		const room = this.room;
		const helpFaqs = roomFaqs[room.roomid];
		if (!helpFaqs) return null;
		const normalized = Chat.normalize(question);
		if (this.data.ignore) {
			if (this.data.ignore.some(t => new RegExp(t, "i").test(normalized))) {
				return null;
			}
		}
		const faqs = Object.keys(helpFaqs).filter(item => !helpFaqs[item].alias);
		for (const faq of faqs) {
			const match = this.test(normalized, faq);
			if (match) {
				if (user) {
					const timestamp = Chat.toTimestamp(new Date()).split(' ')[1];
					const log = `${timestamp} |c| ${user.name}|${question}`;
					this.log(log, faq, match.regex);
				}
				return helpFaqs[match.faq];
			}
		}
		return null;
	}
	visualize(question: string, hideButton?: boolean, user?: User) {
		const response = this.find(question, user);
		if (response) {
			let buf = '';
			buf += Utils.html`<strong>You said:</strong> ${question}<br />`;
			buf += `<strong>Our automated reply:</strong> ${Chat.collapseLineBreaksHTML(visualizeFaq(response))}`;
			if (!hideButton) {
				buf += Utils.html`<hr /><button class="button" name="send" value="A: ${question}">`;
				buf += `Send to ${this.room.title} if you weren't answered correctly. </button>`;
			}
			return buf;
		}
		return null;
	}
	getFaqID(faq?: string) {
		if (!faq) throw new Chat.ErrorMessage(`Your input must be in the format [input] => [faq].`);
		faq = faq.trim();
		if (!faq) throw new Chat.ErrorMessage(`Your FAQ ID can't be empty.`);
		const room = this.room;
		const entry = roomFaqs[room.roomid][faq];
		if (!entry) throw new Chat.ErrorMessage(`FAQ ID "${faq}" not found.`);

		if (!entry.alias) return faq; // not an alias
		return entry.source;
	}
	async getStatsFor(date: string) {
		const stream = FS(LOG_PATH).createReadStream();
		const buf: LoggedMessage[] = [];
		for await (const raw of stream.byLine()) {
			try {
				const data = JSON.parse(raw);
				if (data.date !== date || data.room !== this.room.roomid) continue;
				buf.push(data);
			} catch {}
		}
		return buf;
	}

	async listDays() {
		const stream = FS(LOG_PATH).createReadStream();
		const buf = new Utils.Multiset<string>();
		for await (const raw of stream.byLine()) {
			try {
				const data = JSON.parse(raw);
				if (!data.date || data.room !== this.room.roomid) continue;
				buf.add(data.date);
			} catch {}
		}
		return buf;
	}

	/**
	 * Checks if the FAQ exists. If not, deletes all references to it.
	 */
	updateFaqData(faq: string) {
		// testing purposes
		if (Config.nofswriting) return true;
		const room = this.room;
		if (!room) return;
		if (roomFaqs[room.roomid][faq]) return true;
		if (this.data.pairs[faq]) delete this.data.pairs[faq];
		return false;
	}
	stringRegex(str: string, raw?: boolean) {
		[str] = Utils.splitFirst(str, '=>');
		const args = str.split(',').map(item => item.trim());
		if (!raw && args.length > 10) {
			throw new Chat.ErrorMessage(`Too many arguments.`);
		}
		if (str.length > 300 && !raw) throw new Chat.ErrorMessage("Your given string is too long.");
		return args.map(item => {
			const split = item.split('&').map(string => {
				// allow raw regex for admins and users with @ in Dev
				if (raw) return string;
				// escape
				return string.replace(/[\\^$.*+?()[\]{}]/g, '\\$&').trim();
			});
			return split.map(term => {
				if (term.length > 100 && !raw) {
					throw new Chat.ErrorMessage(`One or more of your arguments is too long. Use less than 100 characters.`);
				}
				if (item.startsWith('|') || item.endsWith('|')) {
					throw new Chat.ErrorMessage(`Invalid use of |. Make sure you have an option on either side.`);
				}
				if (term.startsWith('!')) {
					return `^(?!.*${term.slice(1)})`;
				}
				if (!term.trim()) return null;
				return `(?=.*?(${term.trim()}))`;
			}).filter(Boolean).join('');
		}).filter(Boolean).join('');
	}
	test(question: string, faq: string) {
		if (!this.data.pairs[faq]) this.data.pairs[faq] = [];
		const regexes = this.data.pairs[faq].map(item => new RegExp(item, "i"));
		if (!regexes.length) return;
		for (const regex of regexes) {
			if (regex.test(question)) return { faq, regex: regex.toString() };
		}
		return null;
	}
	log(entry: string, faq: string, expression: string) {
		const [day] = Utils.splitFirst(Chat.toTimestamp(new Date), ' ');
		void AutoResponder.logMessage(this.room.roomid, {
			message: entry,
			faqName: faq,
			regex: expression,
			date: day,
		});
	}
	writeState() {
		for (const faq in this.data.pairs) {
			// while writing, clear old data. In the meantime, the rest of the data is inaccessible
			// so this is the best place to clear the data
			this.updateFaqData(faq);
		}
		answererData[this.room.roomid] = this.data;
		return FS(DATA_PATH).writeUpdate(() => JSON.stringify(answererData));
	}
	tryAddRegex(inputString: string, raw?: boolean) {
		let [args, faq] = inputString.split('=>').map(item => item.trim()) as [string, string | undefined];
		faq = this.getFaqID(toID(faq));
		if (!this.data.pairs) this.data.pairs = {};
		if (!this.data.pairs[faq]) this.data.pairs[faq] = [];
		const regex = raw ? args.trim() : this.stringRegex(args, raw);
		if (this.data.pairs[faq].includes(regex)) {
			throw new Chat.ErrorMessage(`That regex is already stored.`);
		}
		Chat.validateRegex(regex);
		this.data.pairs[faq].push(regex);
		return this.writeState();
	}
	tryRemoveRegex(faq: string, index: number) {
		faq = this.getFaqID(faq);
		if (!this.data.pairs) this.data.pairs = {};
		if (!this.data.pairs[faq]) throw new Chat.ErrorMessage(`There are no regexes for ${faq}.`);
		if (!this.data.pairs[faq][index]) throw new Chat.ErrorMessage("Your provided index is invalid.");
		this.data.pairs[faq].splice(index, 1);
		this.writeState();
		return true;
	}
	static canOverride(user: User, room: Room) {
		const devAuth = Rooms.get('development')?.auth;
		return (devAuth?.atLeast(user, '%') && devAuth?.has(user.id) && room.auth.atLeast(user, '@')) || user.can('rangeban');
	}
	destroy() {
		this.writeState();
		this.room.responder = null;
		this.room = null!;
	}
	ignore(terms: string[], context: Chat.CommandContext) {
		const filtered = terms.map(t => context.filter(t)).filter(Boolean);
		if (filtered.length !== terms.length) {
			throw new Chat.ErrorMessage(`Invalid terms.`);
		}
		if (terms.some(t => t.length > 300)) {
			throw new Chat.ErrorMessage(`One of your terms is too long.`);
		}
		if (!this.data.ignore) this.data.ignore = [];
		this.data.ignore.push(...terms);
		this.writeState();
		return terms;
	}
	unignore(terms: string[]) {
		if (!this.data.ignore) {
			throw new Chat.ErrorMessage(`The autoresponse filter in this room has no ignored terms.`);
		}
		this.data.ignore = this.data.ignore.filter(item => !terms.includes(item));
		this.writeState();
		return true;
	}
}

// update all responders
for (const room of Rooms.rooms.values()) {
	room.responder?.destroy();
	if (answererData[room.roomid]) {
		room.responder = new AutoResponder(room, answererData[room.roomid]);
	}
}

const BYPASS_TERMS = ['a:', 'A:', '!', '/'];

export const chatfilter: Chat.ChatFilter = function (message, user, room) {
	if (BYPASS_TERMS.some(t => message.startsWith(t))) {
		// do not return `message` or it will bypass all filters
		// including super important filters like against `/html`
		return;
	}
	if (room?.responder && room.auth.get(user.id) === ' ') {
		const responder = room.responder;
		const reply = responder.visualize(message, false, user);
		if (!reply) {
			return message;
		} else {
			this.sendReply(`|uhtml|askhelp-${user}-${toID(message)}|<div class="infobox">${reply}</div>`);
			const trimmedMessage = `<div class="infobox">${responder.visualize(message, true)}</div>`;
			setTimeout(() => {
				this.sendReply(`|uhtmlchange|askhelp-${user}-${toID(message)}|${trimmedMessage}`);
			}, 10 * 1000);
			return false;
		}
	}
};

export const commands: Chat.ChatCommands = {
	question(target, room, user) {
		room = this.requireRoom();
		const responder = room.responder;
		if (!responder) throw new Chat.ErrorMessage(`This room does not have an autoresponder configured.`);
		if (!target) return this.parse("/help question");
		const reply = responder.visualize(target, true);
		if (!reply) return this.sendReplyBox(`No answer found.`);
		this.runBroadcast();
		this.sendReplyBox(reply);
	},
	questionhelp: ["/question [question] - Asks the current room's auto-response filter a question."],

	ar: 'autoresponder',
	autoresponder: {
		''(target, room) {
			room = this.requireRoom();
			const responder = room.responder;
			if (!responder) {
				throw new Chat.ErrorMessage(`This room has not configured an autoresponder.`);
			}
			if (!target) {
				return this.parse('/help autoresponder');
			}
			return this.parse(`/j view-autoresponder-${room.roomid}-${target}`);
		},
		view(target, room, user) {
			room = this.requireRoom();
			return this.parse(`/join view-autoresponder-${room.roomid}-${target}`);
		},
		toggle(target, room, user) {
			room = this.requireRoom();
			if (!target) {
				return this.sendReply(
					`The Help auto-response filter is currently set to: ${room.responder ? 'ON' : "OFF"}`
				);
			}
			this.checkCan('ban', null, room);
			if (room.settings.isPrivate === true) {
				throw new Chat.ErrorMessage(`Secret rooms cannot enable an autoresponder.`);
			}
			if (this.meansYes(target)) {
				if (room.responder) throw new Chat.ErrorMessage(`The Autoresponder for this room is already enabled.`);
				room.responder = new AutoResponder(room, answererData[room.roomid]);
				room.responder.writeState();
			}
			if (this.meansNo(target)) {
				if (!room.responder) throw new Chat.ErrorMessage(`The Autoresponder for this room is already disabled.`);
				room.responder.destroy();
			}
			this.privateModAction(`${user.name} ${!room.responder ? 'disabled' : 'enabled'} the auto-response filter.`);
			this.modlog(`AUTOFILTER`, null, !room.responder ? 'OFF' : 'ON');
		},
		forceadd: 'add',
		add(target, room, user, connection, cmd) {
			room = this.requireRoom();
			if (!room.responder) {
				throw new Chat.ErrorMessage(`This room has not configured an auto-response filter.`);
			}
			const force = cmd === 'forceadd';
			if (force && !AutoResponder.canOverride(user, room)) {
				throw new Chat.ErrorMessage(`You cannot use raw regex - use /autoresponder add instead.`);
			}
			this.checkCan('ban', null, room);
			room.responder.tryAddRegex(target, force);
			this.privateModAction(`${user.name} added regex for "${target.split('=>')[0]}" to the autoresponder.`);
			this.modlog(`AUTOFILTER ADD`, null, target);
		},
		remove(target, room, user) {
			const [faq, index] = target.split(',');
			room = this.requireRoom();
			if (!room.responder) {
				throw new Chat.ErrorMessage(`${room.title} has not configured an auto-response filter.`);
			}
			this.checkCan('ban', null, room);
			const num = parseInt(index);
			if (isNaN(num)) throw new Chat.ErrorMessage("Invalid index.");
			room.responder.tryRemoveRegex(faq, num - 1);
			this.privateModAction(`${user.name} removed regex ${num} from the usable regexes for ${faq}.`);
			this.modlog('AUTOFILTER REMOVE', null, `removed regex ${index} for FAQ ${faq}`);
			const pages = [`keys`, `pairs`];
			for (const p of pages) {
				this.refreshPage(`autofilter-${room.roomid}-${p}`);
			}
		},
		ignore(target, room, user) {
			room = this.requireRoom();
			if (!room.responder) {
				throw new Chat.ErrorMessage(`This room has not configured an auto-response filter.`);
			}
			this.checkCan('ban', null, room);
			if (!toID(target)) {
				return this.parse(`/help autoresponder`);
			}
			const targets = target.split(',');
			room.responder.ignore(targets, this);
			this.privateModAction(
				`${user.name} added ${Chat.count(targets.length, "terms")} to the autoresponder ignore list.`
			);
			this.modlog(`AUTOFILTER IGNORE`, null, target);
		},
		unignore(target, room, user) {
			room = this.requireRoom();
			if (!room.responder) {
				throw new Chat.ErrorMessage(`${room.title} has not configured an auto-response filter.`);
			}
			this.checkCan('ban', null, room);
			if (!toID(target)) {
				return this.parse(`/help autoresponder`);
			}
			const targets = target.split(',');
			room.responder.unignore(targets);
			this.privateModAction(`${user.name} removed ${Chat.count(targets.length, "terms")} from the autoresponder ignore list.`);
			this.modlog(`AUTOFILTER UNIGNORE`, null, target);
			if (this.connection.openPages?.has(`autoresponder-${room.roomid}-ignore`)) {
				return this.parse(`/join view-autoresponder-${room.roomid}-ignore`);
			}
		},
	},
	autoresponderhelp() {
		const help = [
			`<code>/autoresponder view [page]</code> - Views the Autoresponder page [page]. (options: keys, stats)`,
			`<code>/autoresponder toggle [on | off]</code> - Enables or disables the Autoresponder for the current room. Requires: @ # ~`,
			`<code>/autoresponder add [input] => [faq]</code> - Adds regex made from the input string to the current room's Autoresponder, to respond with [faq] to matches.`,
			`<code>/autoresponder remove [faq], [regex index]</code> - removes the regex matching the [index] from the current room's responses for [faq].`,
			`Indexes can be found in /autoresponder keys.`,
			`Requires: @ # ~`,
		];
		return this.sendReplyBox(help.join('<br/ >'));
	},
};

export const pages: Chat.PageTable = {
	async autoresponder(args, user) {
		const room = this.requireRoom();
		if (!room.responder) {
			throw new Chat.ErrorMessage(`${room.title} does not have a configured autoresponder.`);
		}
		args.shift();
		const roomData = answererData[room.roomid];
		const canChange = user.can('ban', null, room);
		let buf = '';
		const refresh = (type: string, extra?: string[]) => {
			if (extra) extra = extra.filter(Boolean);
			let button = `<button class="button" name="send" value="/join view-autoresponder-${room.roomid}-${type}`;
			button += `${extra?.length ? `-${extra.join('-')}` : ''}" style="float: right">`;
			button += `<i class="fa fa-refresh"></i> Refresh</button><br />`;
			return button;
		};
		const back = `<br /><a roomid="view-autoresponder-${room.roomid}">Back to all</a>`;
		switch (args[0]) {
		case 'stats':
			args.shift();
			this.checkCan('mute', null, room);
			const date = args.join('-') || '';
			if (!!date && isNaN(new Date(date).getTime())) {
				return `<h2>Invalid date.</h2>`;
			}
			buf = `<div class="pad"><strong>Stats for the ${room.title} auto-response filter${date ? ` on ${date}` : ''}.</strong>`;
			buf += `${back}${refresh('stats', [date])}<hr />`;
			if (date) {
				const stats = await room.responder.getStatsFor(date);
				if (!stats) return `<h2>No stats.</h2>`;
				this.title = `[Autoresponder Stats] ${date ? date : ''}`;
				if (!stats.length) return `<h2>No stats for ${date}.</h2>`;
				buf += `<strong>Total messages answered: ${stats.length}</strong><hr />`;
				buf += `<details><summary>All messages and the corresponding answers (FAQs):</summary>`;
				for (const entry of stats) {
					buf += `<small>Message:</small>${LogViewer.renderLine(entry.message)}`;
					buf += `<small>FAQ: ${entry.faqName}</small><br />`;
					buf += `<small>Regex: <code>${entry.regex}</code></small> <hr />`;
				}
				return LogViewer.linkify(buf);
			}
			buf += `<strong> No date specified.<br />`;
			const days: string[] = [];
			let totalCount = 0;
			const dayKeys = await room.responder.listDays();
			for (const [dateKey, total] of dayKeys) {
				totalCount += total;
				days.push(`- <a roomid="view-autoresponder-${room.roomid}-stats-${dateKey}">${dateKey}</a> (${total})`);
			}
			buf += `Dates with stats:</strong><small>(total matches: ${totalCount})</small><br /><br />`;
			buf += days.join('<br />');
			break;
		case 'pairs':
		case 'keys':
			this.title = '[Autoresponder Regexes]';
			this.checkCan('show', null, room);
			buf = `<div class="pad"><h2>${room.title} responder regexes and responses:</h2>${back}${refresh('keys')}<hr />`;
			buf += Object.entries(roomData.pairs).map(([item, regexes]) => {
				if (regexes.length < 1) return null;
				let buffer = `<details><summary>${item}</summary>`;
				buffer += `<div class="ladder pad"><table><tr><th>Index</th><th>Regex</th>`;
				if (canChange) buffer += `<th>Options</th>`;
				buffer += `</tr>`;
				for (const regex of regexes) {
					const index = regexes.indexOf(regex) + 1;
					const button = `<button class="button" name="send"value="/msgroom ${room.roomid},/ar remove ${item}, ${index}">Remove</button>`;
					buffer += Utils.html`<tr><td>${index}</td><td><code>${regex}</code></td>`;
					if (canChange) buffer += `<td>${button}</td>`;
					buffer += `</tr>`;
				}
				buffer += `</details>`;
				return buffer;
			}).filter(Boolean).join('<hr />');
			break;
		case 'ignore':
			this.title = `[${room.title} Autoresponder ignore list]`;
			buf = `<div class="pad"><h2>${room.title} responder terms to ignore:</h2>${back}${refresh('ignore')}<hr />`;
			if (!roomData.ignore) {
				throw new Chat.ErrorMessage(`No terms on ignore list.`);
			}
			for (const term of roomData.ignore) {
				buf += `- ${Utils.escapeHTML(term)} <button class="button" name="send"value="/msgroom ${room.roomid},/ar unignore ${term}">Remove</button><br />`;
			}
			buf += `</div>`;
			break;
		default:
			this.title = `[${room.title} Autoresponder]`;
			buf = `<div class="pad"><h2>Specify a filter page to view.</h2>`;
			buf += `<hr /><strong>Options:</strong><hr />`;
			buf += `<a roomid="view-autoresponder-${room.roomid}-stats">Stats</a><hr />`;
			buf += `<a roomid="view-autoresponder-${room.roomid}-keys">Regex keys</a><hr/>`;
			buf += `<a roomid="view-autoresponder-${room.roomid}-ignore">Ignore list</a><hr/>`;
			buf += `</div>`;
		}
		return LogViewer.linkify(buf);
	},
};

export const handlers: Chat.Handlers = {
	onRenameRoom(oldID, newID) {
		if (answererData[oldID]) {
			if (!answererData[newID]) answererData[newID] = { pairs: {} };
			Object.assign(answererData[newID], answererData[oldID]);
			delete answererData[oldID];
			FS(DATA_PATH).writeUpdate(() => JSON.stringify(answererData));
		}
	},
};
