import Logger from '@joplin/utils/Logger';
import Setting from '../models/Setting';
import shim from '../shim';
import { basename, toSystemSlashes } from '../path-utils';
import time from '../time';
import { NoteEntity } from './database/types';
import Note from '../models/Note';
import { openFileWithExternalEditor } from './ExternalEditWatcher/utils';
import AsyncActionQueue from '../AsyncActionQueue';
const EventEmitter = require('events');
const chokidar = require('chokidar');
const { ErrorNotFound } = require('./rest/utils/errors');

interface ChangeEventContext {
	path: string;
}

export default class ExternalEditWatcher {

	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
	private dispatch: Function;
	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
	private bridge_: Function;
	private logger_: Logger = new Logger();
	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	private watcher_: any = null;
	private changeEventQueue_: AsyncActionQueue<ChangeEventContext>;
	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	private eventEmitter_: any = new EventEmitter();
	private skipNextChangeEvent_: Record<string, boolean> = {};
	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	private chokidar_: any = chokidar;

	private static instance_: ExternalEditWatcher;

	public static instance() {
		if (this.instance_) return this.instance_;
		this.instance_ = new ExternalEditWatcher();
		return this.instance_;
	}

	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
	public initialize(bridge: Function, dispatch: Function) {
		this.bridge_ = bridge;
		this.dispatch = dispatch;

		// Chokidar skips repeated events for the same file.
		// As a result, it may be possible for something similar to the following to happen:
		// 1. A user makes a change to a file in an external editor.
		// 2. The external editor truncates the file to just before the change.
		// 3. Joplin receives an event from Chokidar and reads the truncated file.
		// 4. The external editor writes the remainder of the file (with the user's change).
		// 5. The change event is ignored (throttled by Chokidar) since it happened within
		//    50 ms of the change from step 2.
		// 6. The note remains truncated in Joplin.
		//
		// To avoid this, we use an AsyncActionQueue to delay processing change events received
		// from Chokidar.
		// The timeout must be at least the throttling delay used internally by chokidar.
		// With Chokidar 3.6.0, this throttling delay is 50ms.
		// https://github.com/laurent22/joplin/issues/14954
		const timeout = 55;
		this.changeEventQueue_ = new AsyncActionQueue<ChangeEventContext>(timeout);
		this.changeEventQueue_.setCanSkipTaskHandler((task1, task2) => {
			// Only skip change events for the same path
			const context1 = task1.context;
			const context2 = task2.context;
			return context1.path === context2.path;
		});
	}

	public externalApi() {
		const loadNote = async (noteId: string) => {
			const note = await Note.load(noteId);
			if (!note) throw new ErrorNotFound(`No such note: ${noteId}`);
			return note;
		};

		return {
			openAndWatch: async (args: { noteId: string }) => {
				const note = await loadNote(args.noteId);
				return this.openAndWatch(note);
			},
			stopWatching: async (args: { noteId: string }) => {
				return this.stopWatching(args.noteId);
			},
			noteIsWatched: async (args: { noteId: string }) => {
				const note = await loadNote(args.noteId);
				return this.noteIsWatched(note);
			},
		};
	}

	public tempDir() {
		return Setting.value('profileDir');
	}

	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
	public on(eventName: string, callback: Function) {
		return this.eventEmitter_.on(eventName, callback);
	}

	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
	public off(eventName: string, callback: Function) {
		return this.eventEmitter_.removeListener(eventName, callback);
	}

	public setLogger(l: Logger) {
		this.logger_ = l;
	}

	public logger() {
		return this.logger_;
	}

	public watch(fileToWatch: string) {
		if (!this.chokidar_) return;

		if (!this.watcher_) {
			this.watcher_ = this.chokidar_.watch(fileToWatch, {
				useFsEvents: false,
			});

			this.watcher_.on('all', async (event: string, path: string) => {
				this.logger().debug(`ExternalEditWatcher: Event: ${event}: ${path}`);

				if (event === 'unlink') {
					// File are unwatched in the stopWatching functions below. When we receive an unlink event
					// here it might be that the file is quickly moved to a different location and replaced by
					// another file with the same name, as it happens with emacs. So because of this
					// we keep watching anyway.
					// See: https://github.com/laurent22/joplin/issues/710#issuecomment-420997167
					// this.watcher_.unwatch(path);
				} else if (event === 'change') {
					const id = this.noteFilePathToId_(path);

					if (!this.skipNextChangeEvent_[id]) {
						this.changeEventQueue_.push(() => this.onNoteChange_(path), { path });
					} else {
						this.logger().debug('ExternalEditWatcher: Skipping this event.');
					}

					this.skipNextChangeEvent_ = {};
				} else if (event === 'error') {
					this.logger().error('ExternalEditWatcher: error');
				}
			});
		} else {
			this.watcher_.add(fileToWatch);
		}

		return this.watcher_;
	}

	private async onNoteChange_(path: string) {
		const id = this.noteFilePathToId_(path);
		const note = await Note.load(id);

		if (!note) {
			this.logger().warn(`ExternalEditWatcher: Watched note has been deleted: ${id}`);
			void this.stopWatching(id);
			return;
		}

		let noteContent: string;
		try {
			noteContent = await shim.fsDriver().readFile(path, 'utf-8');
		} catch (error) {
			if (error.code === 'ENOENT') {
				this.logger().warn(`ExternalEditWatcher: Watched file no longer exists: ${path}`);
				void this.stopWatching(id);
				return;
			}
			throw error;
		}

		// In some very rare cases, the "change" event is going to be emitted but the file will be empty.
		// This is likely to be the editor that first clears the file, then writes the content to it, so if
		// the file content is read very quickly after the change event, we'll get empty content.
		// Usually, re-reading the content again will fix the issue and give back the file content.
		// To replicate on Windows: associate Typora as external editor, and leave Ctrl+S pressed -
		// it will keep on saving very fast and the bug should happen at some point.
		// Below we re-read the file multiple times until we get the content, but in my tests it always
		// work in the first try anyway. The loop is just for extra safety.
		// https://github.com/laurent22/joplin/issues/1854
		if (!noteContent) {
			this.logger().warn(`ExternalEditWatcher: Watched note is empty - this is likely to be a bug and re-reading the note should fix it. Trying again... ${id}`);

			for (let i = 0; i < 10; i++) {
				noteContent = await shim.fsDriver().readFile(path, 'utf-8');
				if (noteContent) {
					this.logger().info(`ExternalEditWatcher: Note is now readable: ${id}`);
					break;
				}
				await time.msleep(100);
			}

			if (!noteContent) this.logger().warn(`ExternalEditWatcher: Could not re-read note - user might have purposely deleted note content: ${id}`);
		}

		this.logger().debug('ExternalEditWatcher: Updating note object.');

		const updatedNote = await Note.unserializeForEdit(noteContent);
		updatedNote.id = id;
		updatedNote.parent_id = note.parent_id;
		await Note.save(updatedNote);
		this.eventEmitter_.emit('noteChange', { id: updatedNote.id, note: updatedNote });
	}

	private noteIdToFilePath_(noteId: string) {
		return `${this.tempDir()}/edit-${noteId}.md`;
	}

	private noteFilePathToId_(path: string) {
		const pathParts = toSystemSlashes(path, 'linux').split('/');
		if (!pathParts.length) throw new Error(`Invalid path: ${path}`);
		const fileName = pathParts[pathParts.length - 1];
		const fileNameParts = fileName.split('.');
		fileNameParts.pop();
		const idParts = fileNameParts[0].split('-');
		return idParts[1];
	}

	public watchedFiles() {
		if (!this.watcher_) return [];

		const output = [];
		const watchedPaths = this.watcher_.getWatched();

		for (const dirName in watchedPaths) {
			if (!watchedPaths.hasOwnProperty(dirName)) continue;

			for (let i = 0; i < watchedPaths[dirName].length; i++) {
				const f = watchedPaths[dirName][i];
				output.push(`${this.tempDir()}/${f}`);
			}
		}

		return output;
	}

	public noteIsWatched(note: NoteEntity) {
		if (!this.watcher_) return false;

		const noteFilename = basename(this.noteIdToFilePath_(note.id));

		const watchedPaths = this.watcher_.getWatched();

		for (const dirName in watchedPaths) {
			if (!watchedPaths.hasOwnProperty(dirName)) continue;

			for (let i = 0; i < watchedPaths[dirName].length; i++) {
				const f = watchedPaths[dirName][i];
				if (f === noteFilename) return true;
			}
		}

		return false;
	}

	public async openAndWatch(note: NoteEntity) {
		if (!note || !note.id) {
			this.logger().warn('ExternalEditWatcher: Cannot open note: ', note);
			return;
		}

		const filePath = await this.writeNoteToFile_(note);
		if (!filePath) return;
		this.watch(filePath);

		await openFileWithExternalEditor(filePath, this.bridge_());

		this.dispatch({
			type: 'NOTE_FILE_WATCHER_ADD',
			id: note.id,
		});

		this.logger().info(`ExternalEditWatcher: Started watching ${filePath}`);
	}

	public async stopWatching(noteId: string) {
		if (!noteId) return;

		await this.changeEventQueue_.processAllNow();

		const filePath = this.noteIdToFilePath_(noteId);
		if (this.watcher_) this.watcher_.unwatch(filePath);
		await shim.fsDriver().remove(filePath);
		this.dispatch({
			type: 'NOTE_FILE_WATCHER_REMOVE',
			id: noteId,
		});
		this.logger().info(`ExternalEditWatcher: Stopped watching ${filePath}`);
	}

	public async stopWatchingAll() {
		await this.changeEventQueue_.processAllNow();

		const filePaths = this.watchedFiles();
		for (let i = 0; i < filePaths.length; i++) {
			await shim.fsDriver().remove(filePaths[i]);
		}

		if (this.watcher_) this.watcher_.close();
		this.watcher_ = null;
		this.logger().info('ExternalEditWatcher: Stopped watching all files');
		this.dispatch({
			type: 'NOTE_FILE_WATCHER_CLEAR',
		});
	}

	public async updateNoteFile(note: NoteEntity) {
		if (!this.noteIsWatched(note)) return;

		if (!note || !note.id) {
			this.logger().warn('ExternalEditWatcher: Cannot update note file: ', note);
			return;
		}

		this.logger().debug(`ExternalEditWatcher: Update note file: ${note.id}`);

		// When the note file is updated programmatically, we skip the next change event to
		// avoid update loops. We only want to listen to file changes made by the user.
		this.skipNextChangeEvent_[note.id] = true;

		await this.writeNoteToFile_(note);
	}

	private async writeNoteToFile_(note: NoteEntity) {
		if (!note || !note.id) {
			this.logger().warn('ExternalEditWatcher: Cannot update note file: ', note);
			return null;
		}

		const filePath = this.noteIdToFilePath_(note.id);
		const noteContent = await Note.serializeForEdit(note);
		await shim.fsDriver().writeFile(filePath, noteContent, 'utf-8');
		return filePath;
	}
}
