/**
 * FS
 * Pokemon Showdown - http://pokemonshowdown.com/
 *
 * An abstraction layer around Node's filesystem.
 *
 * Advantages:
 * - write() etc do nothing in unit tests
 * - paths are always relative to PS's base directory
 * - Promises (seriously wtf Node Core what are you thinking)
 * - PS-style API: FS("foo.txt").write("bar") for easier argument order
 * - mkdirp
 *
 * FS is used nearly everywhere, but exceptions include:
 * - crashlogger.js - in case the crash is in here
 * - repl.js - which use Unix sockets out of this file's scope
 * - launch script - happens before modules are loaded
 * - sim/ - intended to be self-contained
 *
 * @author Guangcong Luo <guangcongluo@gmail.com>
 * @license MIT
 */

import * as fs from 'fs';
import * as pathModule from 'path';
import { ReadStream, WriteStream } from './streams';

// not sure why it's necessary to use path.sep, but testing with Windows showed it was
const DIST = `${pathModule.sep}dist${pathModule.sep}`;
// account for pwd/dist/lib
const ROOT_PATH = pathModule.resolve(__dirname, __dirname.includes(DIST) ? '..' : '', '..');

interface PendingUpdate {
	isWriting: boolean; // true: waiting on a call to FS.write, false: waiting on a throttle
	pendingDataFetcher: (() => string | Buffer) | null;
	pendingOptions: AnyObject | null;
	throttleTime: number; // throttling until time (0 for no throttle)
	throttleTimer: NodeJS.Timeout | null;
}

declare const __fsState: { pendingUpdates: Map<string, PendingUpdate> };
// config needs to be declared here since we access it as global.Config?.nofswriting
// (so we can use it without the global)
declare const global: { __fsState: typeof __fsState, Config: any };
if (!global.__fsState) {
	global.__fsState = {
		pendingUpdates: new Map(),
	};
}

export class FSPath {
	path: string;

	constructor(path: string) {
		this.path = pathModule.resolve(ROOT_PATH, path);
	}

	parentDir() {
		return new FSPath(pathModule.dirname(this.path));
	}

	read(options: AnyObject | BufferEncoding = 'utf8'): Promise<string> {
		if (typeof options !== 'string' && options.encoding === undefined) {
			options.encoding = 'utf8';
		}
		return new Promise((resolve, reject) => {
			fs.readFile(this.path, options, (err, data) => {
				err ? reject(err) : resolve(data as string);
			});
		});
	}

	readSync(options: AnyObject | string = 'utf8'): string {
		if (typeof options !== 'string' && options.encoding === undefined) {
			options.encoding = 'utf8';
		}
		return fs.readFileSync(this.path, options as { encoding: 'utf8' });
	}

	readBuffer(options: AnyObject | BufferEncoding = {}): Promise<Buffer> {
		return new Promise((resolve, reject) => {
			fs.readFile(this.path, options, (err, data) => {
				err ? reject(err) : resolve(data as Buffer);
			});
		});
	}

	readBufferSync(options: AnyObject | string = {}) {
		return fs.readFileSync(this.path, options as { encoding: null });
	}

	exists(): Promise<boolean> {
		return new Promise(resolve => {
			fs.exists(this.path, exists => {
				resolve(exists);
			});
		});
	}

	existsSync() {
		return fs.existsSync(this.path);
	}

	readIfExists(): Promise<string> {
		return new Promise((resolve, reject) => {
			fs.readFile(this.path, 'utf8', (err, data) => {
				if (err && err.code === 'ENOENT') return resolve('');
				err ? reject(err) : resolve(data);
			});
		});
	}

	readIfExistsSync() {
		try {
			return fs.readFileSync(this.path, 'utf8');
		} catch (err: any) {
			if (err.code !== 'ENOENT') throw err;
		}
		return '';
	}

	write(data: string | Buffer, options: AnyObject = {}) {
		if (global.Config?.nofswriting) return Promise.resolve();
		return new Promise<void>((resolve, reject) => {
			fs.writeFile(this.path, data, options, err => {
				err ? reject(err) : resolve();
			});
		});
	}

	writeSync(data: string | Buffer, options: AnyObject = {}) {
		if (global.Config?.nofswriting) return;
		return fs.writeFileSync(this.path, data, options);
	}

	/**
	 * Writes to a new file before renaming to replace an old file. If
	 * the process crashes while writing, the old file won't be lost.
	 * Does not protect against simultaneous writing; use writeUpdate
	 * for that.
	 */
	async safeWrite(data: string | Buffer, options: AnyObject = {}) {
		await FS(this.path + '.NEW').write(data, options);
		await FS(this.path + '.NEW').rename(this.path);
	}

	safeWriteSync(data: string | Buffer, options: AnyObject = {}) {
		FS(this.path + '.NEW').writeSync(data, options);
		FS(this.path + '.NEW').renameSync(this.path);
	}

	/**
	 * Safest way to update a file with in-memory state. Pass a callback
	 * that fetches the data to be written. It will write an update,
	 * avoiding race conditions. The callback may not necessarily be
	 * called, if `writeUpdate` is called many times in a short period.
	 *
	 * `options.throttle`, if it exists, will make sure updates are not
	 * written more than once every `options.throttle` milliseconds.
	 *
	 * No synchronous version because there's no risk of race conditions
	 * with synchronous code; just use `safeWriteSync`.
	 */
	writeUpdate(dataFetcher: () => string | Buffer, options: AnyObject = {}) {
		if (global.Config?.nofswriting) return;
		const pendingUpdate: PendingUpdate | undefined = __fsState.pendingUpdates.get(this.path);

		const throttleTime = options.throttle ? Date.now() + options.throttle : 0;

		if (pendingUpdate) {
			pendingUpdate.pendingDataFetcher = dataFetcher;
			pendingUpdate.pendingOptions = options;
			if (pendingUpdate.throttleTimer && throttleTime < pendingUpdate.throttleTime) {
				pendingUpdate.throttleTime = throttleTime;
				clearTimeout(pendingUpdate.throttleTimer);
				pendingUpdate.throttleTimer = setTimeout(() => this.checkNextUpdate(), throttleTime - Date.now());
			}
			return;
		}

		if (!throttleTime) {
			this.writeUpdateNow(dataFetcher, options);
			return;
		}

		const update: PendingUpdate = {
			isWriting: false,
			pendingDataFetcher: dataFetcher,
			pendingOptions: options,
			throttleTime,
			throttleTimer: setTimeout(() => this.checkNextUpdate(), throttleTime - Date.now()),
		};
		__fsState.pendingUpdates.set(this.path, update);
	}

	writeUpdateNow(dataFetcher: () => string | Buffer, options: AnyObject) {
		const throttleTime = options.throttle ? Date.now() + options.throttle : 0;
		const update = {
			isWriting: true,
			pendingDataFetcher: null,
			pendingOptions: null,
			throttleTime,
			throttleTimer: null,
		};
		__fsState.pendingUpdates.set(this.path, update);
		void this.safeWrite(dataFetcher(), options).then(() => this.finishUpdate());
	}
	checkNextUpdate() {
		const pendingUpdate = __fsState.pendingUpdates.get(this.path);
		if (!pendingUpdate) throw new Error(`FS: Pending update not found`);
		if (pendingUpdate.isWriting) throw new Error(`FS: Conflicting update`);

		const { pendingDataFetcher: dataFetcher, pendingOptions: options } = pendingUpdate;
		if (!dataFetcher || !options) {
			// no pending update
			__fsState.pendingUpdates.delete(this.path);
			return;
		}

		this.writeUpdateNow(dataFetcher, options);
	}
	finishUpdate() {
		const pendingUpdate = __fsState.pendingUpdates.get(this.path);
		if (!pendingUpdate) throw new Error(`FS: Pending update not found`);
		if (!pendingUpdate.isWriting) throw new Error(`FS: Conflicting update`);

		pendingUpdate.isWriting = false;
		const throttleTime = pendingUpdate.throttleTime;
		if (!throttleTime || throttleTime < Date.now()) {
			this.checkNextUpdate();
			return;
		}

		pendingUpdate.throttleTimer = setTimeout(() => this.checkNextUpdate(), throttleTime - Date.now());
	}

	append(data: string | Buffer, options: AnyObject = {}) {
		if (global.Config?.nofswriting) return Promise.resolve();
		return new Promise<void>((resolve, reject) => {
			fs.appendFile(this.path, data, options, err => {
				err ? reject(err) : resolve();
			});
		});
	}

	appendSync(data: string | Buffer, options: AnyObject = {}) {
		if (global.Config?.nofswriting) return;
		return fs.appendFileSync(this.path, data, options);
	}

	symlinkTo(target: string) {
		if (global.Config?.nofswriting) return Promise.resolve();
		return new Promise<void>((resolve, reject) => {
			fs.symlink(target, this.path, err => {
				err ? reject(err) : resolve();
			});
		});
	}

	symlinkToSync(target: string) {
		if (global.Config?.nofswriting) return;
		return fs.symlinkSync(target, this.path);
	}

	copyFile(dest: string) {
		if (global.Config?.nofswriting) return Promise.resolve();
		return new Promise<void>((resolve, reject) => {
			fs.copyFile(this.path, dest, err => {
				err ? reject(err) : resolve();
			});
		});
	}

	rename(target: string) {
		if (global.Config?.nofswriting) return Promise.resolve();
		return new Promise<void>((resolve, reject) => {
			fs.rename(this.path, target, err => {
				err ? reject(err) : resolve();
			});
		});
	}

	renameSync(target: string) {
		if (global.Config?.nofswriting) return;
		return fs.renameSync(this.path, target);
	}

	readdir(): Promise<string[]> {
		return new Promise((resolve, reject) => {
			fs.readdir(this.path, (err, data) => {
				err ? reject(err) : resolve(data);
			});
		});
	}

	readdirSync() {
		return fs.readdirSync(this.path);
	}

	async readdirIfExists(): Promise<string[]> {
		if (await this.exists()) return this.readdir();
		return Promise.resolve([]);
	}

	readdirIfExistsSync() {
		if (this.existsSync()) return this.readdirSync();
		return [];
	}

	createReadStream() {
		return new FileReadStream(this.path);
	}

	createWriteStream(options = {}): WriteStream {
		if (global.Config?.nofswriting) {
			return new WriteStream({ write() {} });
		}
		return new WriteStream(fs.createWriteStream(this.path, options));
	}

	createAppendStream(options: AnyObject = {}): WriteStream {
		if (global.Config?.nofswriting) {
			return new WriteStream({ write() {} });
		}
		options.flags = options.flags || 'a';
		return new WriteStream(fs.createWriteStream(this.path, options));
	}

	unlinkIfExists() {
		if (global.Config?.nofswriting) return Promise.resolve();
		return new Promise<void>((resolve, reject) => {
			fs.unlink(this.path, err => {
				if (err && err.code === 'ENOENT') return resolve();
				err ? reject(err) : resolve();
			});
		});
	}

	unlinkIfExistsSync() {
		if (global.Config?.nofswriting) return;
		try {
			fs.unlinkSync(this.path);
		} catch (err: any) {
			if (err.code !== 'ENOENT') throw err;
		}
	}

	async rmdir(recursive?: boolean) {
		if (global.Config?.nofswriting) return Promise.resolve();
		return new Promise<void>((resolve, reject) => {
			fs.rmdir(this.path, { recursive }, err => {
				err ? reject(err) : resolve();
			});
		});
	}

	rmdirSync(recursive?: boolean) {
		if (global.Config?.nofswriting) return;
		return fs.rmdirSync(this.path, { recursive });
	}

	mkdir(mode: string | number = 0o755) {
		if (global.Config?.nofswriting) return Promise.resolve();
		return new Promise<void>((resolve, reject) => {
			fs.mkdir(this.path, mode, err => {
				err ? reject(err) : resolve();
			});
		});
	}

	mkdirSync(mode: string | number = 0o755) {
		if (global.Config?.nofswriting) return;
		return fs.mkdirSync(this.path, mode);
	}

	mkdirIfNonexistent(mode: string | number = 0o755) {
		if (global.Config?.nofswriting) return Promise.resolve();
		return new Promise<void>((resolve, reject) => {
			fs.mkdir(this.path, mode, err => {
				if (err && err.code === 'EEXIST') return resolve();
				err ? reject(err) : resolve();
			});
		});
	}

	mkdirIfNonexistentSync(mode: string | number = 0o755) {
		if (global.Config?.nofswriting) return;
		try {
			fs.mkdirSync(this.path, mode);
		} catch (err: any) {
			if (err.code !== 'EEXIST') throw err;
		}
	}

	/**
	 * Creates the directory (and any parent directories if necessary).
	 * Does not throw if the directory already exists.
	 */
	async mkdirp(mode: string | number = 0o755) {
		try {
			await this.mkdirIfNonexistent(mode);
		} catch (err: any) {
			if (err.code !== 'ENOENT') throw err;
			await this.parentDir().mkdirp(mode);
			await this.mkdirIfNonexistent(mode);
		}
	}

	/**
	 * Creates the directory (and any parent directories if necessary).
	 * Does not throw if the directory already exists. Synchronous.
	 */
	mkdirpSync(mode: string | number = 0o755) {
		try {
			this.mkdirIfNonexistentSync(mode);
		} catch (err: any) {
			if (err.code !== 'ENOENT') throw err;
			this.parentDir().mkdirpSync(mode);
			this.mkdirIfNonexistentSync(mode);
		}
	}

	/** Calls the callback if the file is modified. */
	onModify(callback: () => void) {
		fs.watchFile(this.path, (curr, prev) => {
			if (curr.mtime > prev.mtime) return callback();
		});
	}

	/** Clears callbacks added with onModify(). */
	unwatch() {
		fs.unwatchFile(this.path);
	}

	async isFile() {
		return new Promise<boolean>((resolve, reject) => {
			fs.stat(this.path, (err, stats) => {
				err ? reject(err) : resolve(stats.isFile());
			});
		});
	}

	isFileSync() {
		return fs.statSync(this.path).isFile();
	}

	async isDirectory() {
		return new Promise<boolean>((resolve, reject) => {
			fs.stat(this.path, (err, stats) => {
				err ? reject(err) : resolve(stats.isDirectory());
			});
		});
	}

	isDirectorySync() {
		return fs.statSync(this.path).isDirectory();
	}

	async realpath() {
		return new Promise<string>((resolve, reject) => {
			fs.realpath(this.path, (err, path) => {
				err ? reject(err) : resolve(path);
			});
		});
	}

	realpathSync() {
		return fs.realpathSync(this.path);
	}
}

class FileReadStream extends ReadStream {
	fd: Promise<number>;

	constructor(file: string) {
		super();
		this.fd = new Promise((resolve, reject) => {
			fs.open(file, 'r', (err, fd) => err ? reject(err) : resolve(fd));
		});
		this.atEOF = false;
	}

	override _read(size = 16384): Promise<void> {
		return new Promise<void>((resolve, reject) => {
			if (this.atEOF) return void resolve();
			this.ensureCapacity(size);
			void this.fd.then(fd => {
				fs.read(fd, this.buf, this.bufEnd, size, null, (err, bytesRead, buf) => {
					if (err) return reject(err);
					if (!bytesRead) {
						this.atEOF = true;
						this.resolvePush();
						return resolve();
					}
					this.bufEnd += bytesRead;
					// throw new Error([...this.buf].map(x => x.toString(16)).join(' '));
					this.resolvePush();
					resolve();
				});
			});
		});
	}

	override _destroy() {
		return new Promise<void>(resolve => {
			void this.fd.then(fd => {
				fs.close(fd, () => resolve());
			});
		});
	}
}

function getFs(path: string) {
	return new FSPath(path);
}

export const FS = Object.assign(getFs, {
	FileReadStream, FSPath, ROOT_PATH,
});
