const { Logger } = require('lib/logger.js');
const Note = require('lib/models/Note');
const Setting = require('lib/models/Setting');
const { shim } = require('lib/shim');
const EventEmitter = require('events');
const { splitCommandString } = require('lib/string-utils');
const { fileExtension, basename } = require('lib/path-utils');
const spawn	= require('child_process').spawn;
const chokidar = require('chokidar');

class ExternalEditWatcher {

	constructor() {
		this.logger_ = new Logger();
		this.dispatch = (action) => {};
		this.watcher_ = null;
		this.eventEmitter_ = new EventEmitter();
		this.skipNextChangeEvent_ = {};
		this.chokidar_ = chokidar;
	}

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

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

	on(eventName, callback) {
		return this.eventEmitter_.on(eventName, callback);
	}

	off(eventName, callback) {
		return this.eventEmitter_.removeListener(eventName, callback);
	}

	setLogger(l) {
		this.logger_ = l;
	}

	logger() {
		return this.logger_;
	}

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

		if (!this.watcher_) {
			this.watcher_ = this.chokidar_.watch(fileToWatch);
			this.watcher_.on('all', async (event, path) => {
				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('Watched note has been deleted: ' + id);
							this.stopWatching(id);
							return;
						}

						const noteContent = await shim.fsDriver().readFile(path, 'utf-8');
						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 });
					}

					this.skipNextChangeEvent_ = {};
				} else if (event === 'error') {
					this.logger().error('ExternalEditWatcher:');
					this.logger().error(error)
				}
			});
			// Hack to support external watcher on some linux applications (gedit, gvim, etc)
			// taken from https://github.com/paulmillr/chokidar/issues/591
			this.watcher_.on('raw', async (event, path, {watchedPath}) => {
				if (event === 'rename') {
					this.watcher_.unwatch(watchedPath);
					this.watcher_.add(watchedPath);
				}
			});
		} else {
			this.watcher_.add(fileToWatch);
		}

		return this.watcher_;
	}

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

	noteIdToFilePath_(noteId) {
		return this.tempDir() + '/edit-' + noteId + '.md';
	}

	noteFilePathToId_(path) {
		let id = path.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];
	}

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

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

		for (let 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;
	}

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

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

		const watchedPaths = this.watcher_.getWatched();

		for (let 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;
	}

	textEditorCommand() {
		const editorCommand = Setting.value('editor');
		if (!editorCommand) return null;

		const s = splitCommandString(editorCommand, {handleEscape: false});
		const path = s.splice(0, 1);
		if (!path.length) throw new Error('Invalid editor command: ' + editorCommand);

		return {
			path: path[0],
			args: s,
		};
	}

	async spawnCommand(path, args, options) {
		return new Promise((resolve, reject) => {

			// App bundles need to be opened using the `open` command.
			// Additional args can be specified after --args, and the 
			// -n flag is needed to ensure that the app is always launched
			// with the arguments. Without it, if the app is already opened,
			// it will just bring it to the foreground without opening the file.
			// So the full command is:
			//
			// open -n /path/to/editor.app --args -app-flag -bla /path/to/file.md
			//
			if (shim.isMac() && fileExtension(path) === 'app') {
				args = args.slice();
				args.splice(0, 0, '--args');
				args.splice(0, 0, path);
				args.splice(0, 0, '-n');
				path = 'open';
			}

			const wrapError = (error) => {
				if (!error) return error;
				let msg = error.message ? [error.message] : [];
				msg.push('Command was: "' + path + '" ' + args.join(' '));
				error.message = msg.join('\n\n');
				return error;
			}

			try {
				const subProcess = spawn(path, args, options);

				const iid = setInterval(() => {
					if (subProcess && subProcess.pid) {
						this.logger().debug('Started editor with PID ' + subProcess.pid);
						clearInterval(iid);
						resolve();
					}
				}, 100);

				subProcess.on('error', (error) => {
					clearInterval(iid);
					reject(wrapError(error));
				});
			} catch (error) {
				throw wrapError(error);
			}
		});
	}

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

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

		const cmd = this.textEditorCommand();
		if (!cmd) {
			bridge().openExternal('file://' + filePath);
		} else {
			cmd.args.push(filePath);
			await this.spawnCommand(cmd.path, cmd.args, { detached: true });
		}

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

		this.logger().info('ExternalEditWatcher: Started watching ' + filePath);
	}

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

	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',
		});
	}

	async updateNoteFile(note) {
		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;

		this.writeNoteToFile_(note);
	}

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

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

}

module.exports = ExternalEditWatcher;