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';
const EventEmitter = require('events');
const chokidar = require('chokidar');
const { ErrorNotFound } = require('./rest/utils/errors');

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;
	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	private eventEmitter_: any = new EventEmitter();
	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	private skipNextChangeEvent_: any = {};
	// 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;
	}

	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 {
			// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
			openAndWatch: async (args: any) => {
				const note = await loadNote(args.noteId);
				return this.openAndWatch(note);
			},
			// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
			stopWatching: async (args: any) => {
				return this.stopWatching(args.noteId);
			},
			// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
			noteIsWatched: async (args: any) => {
				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]) {
						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 = await shim.fsDriver().readFile(path, 'utf-8');

						// 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 });
					} 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 noteIdToFilePath_(noteId: string) {
		return `${this.tempDir()}/edit-${noteId}.md`;
	}

	private noteFilePathToId_(path: string) {
		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
		let id: any = toSystemSlashes(path, 'linux').split('/');
		if (!id.length) throw new Error(`Invalid path: ${path}`);
		id = id[id.length - 1];
		id = id.split('.');
		id.pop();
		id = id[0].split('-');
		return id[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;

		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() {
		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;
	}
}
