/**
 * Roomlogs
 * Pokemon Showdown - http://pokemonshowdown.com/
 *
 * This handles data storage for rooms.
 *
 * @license MIT
 */

import {FS, Utils, type Streams} from '../lib';
import {PGDatabase, SQL, SQLStatement} from '../lib/database';
import type {PartialModlogEntry} from './modlog';

interface RoomlogOptions {
	isMultichannel?: boolean;
	noAutoTruncate?: boolean;
	noLogTimes?: boolean;
}

interface RoomlogRow {
	type: string;
	roomid: string;
	userid: string | null;
	time: Date;
	log: string;
	// tsvector, really don't use
	content: string | null;
}

export const roomlogDB = (() => {
	if (!global.Config || !Config.replaysdb || Config.disableroomlogdb) return null;
	return new PGDatabase(Config.replaysdb);
})();
export const roomlogTable = roomlogDB?.getTable<RoomlogRow>('roomlogs');

/**
 * Most rooms have three logs:
 * - scrollback
 * - roomlog
 * - modlog
 * This class keeps track of all three.
 *
 * The scrollback is stored in memory, and is the log you get when you
 * join the room. It does not get moderator messages.
 *
 * The modlog is stored in
 * `logs/modlog/modlog_<ROOMID>.txt`
 * It contains moderator messages, formatted for ease of search.
 * Direct modlog access is handled in server/modlog/; this file is just
 * a wrapper to make other code more readable.
 *
 * The roomlog is stored in
 * `logs/chat/<ROOMID>/<YEAR>-<MONTH>/<YEAR>-<MONTH>-<DAY>.txt`
 * It contains (nearly) everything.
 */
export class Roomlog {
	/**
	 * Battle rooms are multichannel, which means their logs are split
	 * into four channels, public, p1, p2, full.
	 */
	readonly isMultichannel: boolean;
	/**
	 * Chat rooms auto-truncate, which means it only stores the recent
	 * messages, if there are more.
	 */
	readonly noAutoTruncate: boolean;
	/**
	 * Chat rooms include timestamps.
	 */
	readonly noLogTimes: boolean;
	roomid: RoomID;
	/**
	 * Scrollback log
	 */
	log: string[];
	visibleMessageCount = 0;
	broadcastBuffer: string[];
	/**
	 * undefined = uninitialized,
	 * null = disabled
	 */
	roomlogStream?: Streams.WriteStream | null;
	/**
	 * Takes precedence over roomlogStream if it exists.
	 */
	roomlogTable: typeof roomlogTable;
	roomlogFilename: string;

	numTruncatedLines: number;
	constructor(room: BasicRoom, options: RoomlogOptions = {}) {
		this.roomid = room.roomid;

		this.isMultichannel = !!options.isMultichannel;
		this.noAutoTruncate = !!options.noAutoTruncate;
		this.noLogTimes = !!options.noLogTimes;

		this.log = [];
		this.broadcastBuffer = [];

		this.roomlogStream = undefined;
		this.roomlogFilename = '';

		this.numTruncatedLines = 0;

		this.setupRoomlogStream();
	}
	getScrollback(channel = 0) {
		let log = this.log;
		if (!this.noLogTimes) log = [`|:|${~~(Date.now() / 1000)}`].concat(log);
		if (!this.isMultichannel) {
			return log.join('\n') + '\n';
		}
		log = [];
		for (let i = 0; i < this.log.length; ++i) {
			const line = this.log[i];
			const split = /\|split\|p(\d)/g.exec(line);
			if (split) {
				const canSeePrivileged = (channel === Number(split[1]) || channel === -1);
				const ownLine = this.log[i + (canSeePrivileged ? 1 : 2)];
				if (ownLine) log.push(ownLine);
				i += 2;
			} else {
				log.push(line);
			}
		}
		return log.join('\n') + '\n';
	}
	setupRoomlogStream() {
		if (this.roomlogStream === null) return;
		if (!Config.logchat || this.roomid.startsWith('battle-') || this.roomid.startsWith('game-')) {
			this.roomlogStream = null;
			return;
		}
		if (roomlogTable) {
			this.roomlogTable = roomlogTable;
			this.roomlogStream = null;
			return;
		}
		const date = new Date();
		const dateString = Chat.toTimestamp(date).split(' ')[0];
		const monthString = dateString.split('-', 2).join('-');
		const basepath = `chat/${this.roomid}/`;
		const relpath = `${monthString}/${dateString}.txt`;

		if (relpath === this.roomlogFilename) return;

		Monitor.logPath(basepath + monthString).mkdirpSync();
		this.roomlogFilename = relpath;
		if (this.roomlogStream) void this.roomlogStream.writeEnd();
		this.roomlogStream = Monitor.logPath(basepath + relpath).createAppendStream();
		// Create a symlink to today's lobby log.
		// These operations need to be synchronous, but it's okay
		// because this code is only executed once every 24 hours.
		const link0 = basepath + 'today.txt.0';
		Monitor.logPath(link0).unlinkIfExistsSync();
		try {
			Monitor.logPath(link0).symlinkToSync(relpath); // intentionally a relative link
			Monitor.logPath(link0).renameSync(basepath + 'today.txt');
		} catch {} // OS might not support symlinks or atomic rename
		if (!Roomlogs.rollLogTimer) Roomlogs.rollLogs();
	}
	add(message: string) {
		this.roomlog(message);
		// |uhtml gets both uhtml and uhtmlchange
		// which are visible and so should be counted
		if (['|c|', '|c:|', '|raw|', '|html|', '|uhtml'].some(k => message.startsWith(k))) {
			this.visibleMessageCount++;
		}
		message = this.withTimestamp(message);
		this.log.push(message);
		this.broadcastBuffer.push(message);
		return this;
	}
	private withTimestamp(message: string) {
		if (!this.noLogTimes && message.startsWith('|c|')) {
			return `|c:|${Math.trunc(Date.now() / 1000)}|${message.slice(3)}`;
		} else {
			return message;
		}
	}
	hasUsername(username: string) {
		const userid = toID(username);
		for (const line of this.log) {
			if (line.startsWith('|c:|')) {
				const curUserid = toID(line.split('|', 4)[3]);
				if (curUserid === userid) return true;
			} else if (line.startsWith('|c|')) {
				const curUserid = toID(line.split('|', 3)[2]);
				if (curUserid === userid) return true;
			}
		}
		return false;
	}
	clearText(userids: ID[], lineCount = 0) {
		const cleared: ID[] = [];
		const clearAll = (lineCount === 0);
		this.log = this.log.reverse().filter(line => {
			const parsed = this.parseChatLine(line);
			if (parsed) {
				const userid = toID(parsed.user);
				if (userids.includes(userid)) {
					if (!cleared.includes(userid)) cleared.push(userid);
					// Don't remove messages in battle rooms to preserve evidence
					if (!this.roomlogStream && !this.roomlogTable) return true;
					if (clearAll) return false;
					if (lineCount > 0) {
						lineCount--;
						return false;
					}
					return true;
				}
			}
			return true;
		}).reverse();
		return cleared;
	}
	uhtmlchange(name: string, message: string) {
		const originalStart = '|uhtml|' + name + '|';
		const fullMessage = originalStart + message;
		for (const [i, line] of this.log.entries()) {
			if (line.startsWith(originalStart)) {
				this.log[i] = fullMessage;
				break;
			}
		}
		this.broadcastBuffer.push(fullMessage);
	}
	attributedUhtmlchange(user: User, name: string, message: string) {
		const start = `/uhtmlchange ${name},`;
		const fullMessage = this.withTimestamp(`|c|${user.getIdentity()}|${start}${message}`);
		let matched = false;
		for (const [i, line] of this.log.entries()) {
			if (this.parseChatLine(line)?.message.startsWith(start)) {
				this.log[i] = fullMessage;
				matched = true;
				break;
			}
		}
		if (!matched) this.log.push(fullMessage);
		this.broadcastBuffer.push(fullMessage);
	}
	parseChatLine(line: string) {
		const prefixes: [string, number][] = [['|c:|', 4], ['|c|', 3]];
		for (const [messageStart, section] of prefixes) {
			// const messageStart = !this.noLogTimes ? '|c:|' : '|c|';
			// const section = !this.noLogTimes ? 4 : 3; // ['', 'c' timestamp?, author, message]
			if (line.startsWith(messageStart)) {
				const parts = Utils.splitFirst(line, '|', section);
				return {user: parts[section - 1], message: parts[section]};
			}
		}
	}
	roomlog(message: string, date = new Date()) {
		if (!Config.logchat) return;
		message = message.replace(/<img[^>]* src="data:image\/png;base64,[^">]+"[^>]*>/g, '[img]');
		if (this.roomlogTable) {
			const chatData = this.parseChatLine(message);
			const type = message.split('|')[1] || "";
			void this.insertLog(SQL`INSERT INTO roomlogs (${{
				type: type,
				roomid: this.roomid,
				userid: toID(chatData?.user) || null,
				time: SQL`now()`,
				log: message,
		  }})`);

			const dateStr = Chat.toTimestamp(date).split(' ')[0];
			void this.insertLog(SQL`INSERT INTO roomlog_dates (${{
				roomid: this.roomid,
				month: dateStr.slice(0, -3),
				date: dateStr,
			}}) ON CONFLICT (roomid, date) DO NOTHING;`);
		} else if (this.roomlogStream) {
			const timestamp = Chat.toTimestamp(date).split(' ')[1] + ' ';
			void this.roomlogStream.write(timestamp + message + '\n');
		}
	}
	private async insertLog(query: SQLStatement, ignoreFailure = false, retries = 3): Promise<void> {
		try {
			await this.roomlogTable?.query(query);
		} catch (e: any) {
			if (e?.code === '42P01') { // table not found
				await roomlogDB!._query(FS('databases/schemas/roomlogs.sql').readSync(), []);
				return this.insertLog(query, ignoreFailure, retries);
			}
			// connection terminated / transient errors
			if (
				!ignoreFailure &&
				retries > 0 &&
				e.message?.includes('Connection terminated unexpectedly')
			) {
				// delay before retrying
				await new Promise(resolve => setTimeout(resolve, 2000));
				return this.insertLog(query, ignoreFailure, retries - 1);
			}
			// crashlog for all other errors
			const [q, vals] = roomlogDB!._resolveSQL(query);
			Monitor.crashlog(e, 'a roomlog database query', {
				query: q, values: vals,
			});
		}
	}
	modlog(entry: PartialModlogEntry, overrideID?: string) {
		void Rooms.Modlog.write(this.roomid, entry, overrideID);
	}
	async rename(newID: RoomID): Promise<true> {
		await Rooms.Modlog.rename(this.roomid, newID);
		const roomlogStreamExisted = this.roomlogStream !== null;
		await this.destroy();
		if (this.roomlogTable) {
			await this.roomlogTable.updateAll({roomid: newID})`WHERE roomid = ${this.roomid}`;
		} else {
			const roomlogPath = `chat`;
			const [roomlogExists, newRoomlogExists] = await Promise.all([
				Monitor.logPath(roomlogPath + `/${this.roomid}`).exists(),
				Monitor.logPath(roomlogPath + `/${newID}`).exists(),
			]);
			if (roomlogExists && !newRoomlogExists) {
				await Monitor.logPath(roomlogPath + `/${this.roomid}`).rename(Monitor.logPath(roomlogPath + `/${newID}`).path);
			}
			if (roomlogStreamExisted) {
				this.roomlogStream = undefined;
				this.roomlogFilename = "";
				this.setupRoomlogStream();
			}
		}
		Roomlogs.roomlogs.set(newID, this);
		this.roomid = newID;
		return true;
	}
	static rollLogs() {
		if (Roomlogs.rollLogTimer === true) return;
		if (Roomlogs.rollLogTimer) {
			clearTimeout(Roomlogs.rollLogTimer);
		}
		Roomlogs.rollLogTimer = true;
		for (const log of Roomlogs.roomlogs.values()) {
			log.setupRoomlogStream();
		}
		const time = Date.now();
		const nextMidnight = new Date();
		nextMidnight.setHours(24, 0, 0, 0);
		Roomlogs.rollLogTimer = setTimeout(() => Roomlog.rollLogs(), nextMidnight.getTime() - time);
	}
	truncate() {
		if (this.noAutoTruncate) return;
		if (this.log.length > 100) {
			const truncationLength = this.log.length - 100;
			this.log.splice(0, truncationLength);
			this.numTruncatedLines += truncationLength;
		}
	}
	/**
	 * Returns the total number of lines in the roomlog, including truncated lines.
	 */
	getLineCount(onlyVisible = true) {
		return (onlyVisible ? this.visibleMessageCount : this.log.length) + this.numTruncatedLines;
	}

	destroy() {
		const promises = [];
		if (this.roomlogStream) {
			promises.push(this.roomlogStream.writeEnd());
			this.roomlogStream = null;
		}
		Roomlogs.roomlogs.delete(this.roomid);
		return Promise.all(promises);
	}
}

const roomlogs = new Map<string, Roomlog>();

function createRoomlog(room: BasicRoom, options = {}) {
	let roomlog = Roomlogs.roomlogs.get(room.roomid);
	if (roomlog) throw new Error(`Roomlog ${room.roomid} already exists`);

	roomlog = new Roomlog(room, options);
	Roomlogs.roomlogs.set(room.roomid, roomlog);
	return roomlog;
}

export const Roomlogs = {
	create: createRoomlog,
	Roomlog,
	roomlogs,
	db: roomlogDB,
	table: roomlogTable,

	rollLogs: Roomlog.rollLogs,

	rollLogTimer: null as NodeJS.Timeout | true | null,
};
