"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const Logger_1 = require("@joplin/utils/Logger");
const Setting_1 = require("../models/Setting");
const shim_1 = require("../shim");
const path_utils_1 = require("../path-utils");
const time_1 = require("../time");
const Note_1 = require("../models/Note");
const utils_1 = require("./ExternalEditWatcher/utils");
const AsyncActionQueue_1 = require("../AsyncActionQueue");
const EventEmitter = require('events');
const chokidar = require('chokidar');
const { ErrorNotFound } = require('./rest/utils/errors');
class ExternalEditWatcher {
    constructor() {
        this.logger_ = new Logger_1.default();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
        this.watcher_ = null;
        // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
        this.eventEmitter_ = new EventEmitter();
        this.skipNextChangeEvent_ = {};
        // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
        this.chokidar_ = chokidar;
    }
    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
    initialize(bridge, dispatch) {
        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_1.default(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;
        });
    }
    externalApi() {
        const loadNote = async (noteId) => {
            const note = await Note_1.default.load(noteId);
            if (!note)
                throw new ErrorNotFound(`No such note: ${noteId}`);
            return note;
        };
        return {
            openAndWatch: async (args) => {
                const note = await loadNote(args.noteId);
                return this.openAndWatch(note);
            },
            stopWatching: async (args) => {
                return this.stopWatching(args.noteId);
            },
            noteIsWatched: async (args) => {
                const note = await loadNote(args.noteId);
                return this.noteIsWatched(note);
            },
        };
    }
    tempDir() {
        return Setting_1.default.value('profileDir');
    }
    // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
    on(eventName, callback) {
        return this.eventEmitter_.on(eventName, callback);
    }
    // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
    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, {
                useFsEvents: false,
            });
            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]) {
                        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_;
    }
    async onNoteChange_(path) {
        const id = this.noteFilePathToId_(path);
        const note = await Note_1.default.load(id);
        if (!note) {
            this.logger().warn(`ExternalEditWatcher: Watched note has been deleted: ${id}`);
            void this.stopWatching(id);
            return;
        }
        let noteContent;
        try {
            noteContent = await shim_1.default.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_1.default.fsDriver().readFile(path, 'utf-8');
                if (noteContent) {
                    this.logger().info(`ExternalEditWatcher: Note is now readable: ${id}`);
                    break;
                }
                await time_1.default.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_1.default.unserializeForEdit(noteContent);
        updatedNote.id = id;
        updatedNote.parent_id = note.parent_id;
        await Note_1.default.save(updatedNote);
        this.eventEmitter_.emit('noteChange', { id: updatedNote.id, note: updatedNote });
    }
    noteIdToFilePath_(noteId) {
        return `${this.tempDir()}/edit-${noteId}.md`;
    }
    noteFilePathToId_(path) {
        const pathParts = (0, path_utils_1.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];
    }
    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;
    }
    noteIsWatched(note) {
        if (!this.watcher_)
            return false;
        const noteFilename = (0, path_utils_1.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;
    }
    async openAndWatch(note) {
        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 (0, utils_1.openFileWithExternalEditor)(filePath, this.bridge_());
        this.dispatch({
            type: 'NOTE_FILE_WATCHER_ADD',
            id: note.id,
        });
        this.logger().info(`ExternalEditWatcher: Started watching ${filePath}`);
    }
    async stopWatching(noteId) {
        if (!noteId)
            return;
        await this.changeEventQueue_.processAllNow();
        const filePath = this.noteIdToFilePath_(noteId);
        if (this.watcher_)
            this.watcher_.unwatch(filePath);
        await shim_1.default.fsDriver().remove(filePath);
        this.dispatch({
            type: 'NOTE_FILE_WATCHER_REMOVE',
            id: noteId,
        });
        this.logger().info(`ExternalEditWatcher: Stopped watching ${filePath}`);
    }
    async stopWatchingAll() {
        await this.changeEventQueue_.processAllNow();
        const filePaths = this.watchedFiles();
        for (let i = 0; i < filePaths.length; i++) {
            await shim_1.default.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;
        await this.writeNoteToFile_(note);
    }
    async writeNoteToFile_(note) {
        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_1.default.serializeForEdit(note);
        await shim_1.default.fsDriver().writeFile(filePath, noteContent, 'utf-8');
        return filePath;
    }
}
exports.default = ExternalEditWatcher;
//# sourceMappingURL=ExternalEditWatcher.js.map