// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later

define([
    'jquery',
    'json.sortify',
    '/common/common-util.js',
    '/common/common-hash.js',
    '/common/hyperscript.js',
    '/common/common-interface.js',
    '/common/common-ui-elements.js',
    '/customize/messages.js'
], function($, Sortify, Util, Hash, h, UI, UIElements, Messages) {
    var Comments = {};

    /*
    {
        authors: {
            "id": {
                name: "",
                curvePublic: "",
                avatar: "",
                profile: ""
            }
        },
        data: {
            "uid": {
                m: [{
                    u: id,
                    m: "str", // comment
                    t: +new Date,
                    v: "str", // value of the commented content
                    e: undefined/1, // edited
                    d: undefined/1, // deleted
                }],
                d: undefined/1,
            }
        }
    }
    */

    var COMMENTS = {
        authors: {},
        data: {}
    };

    var canonicalize = function(t) { return t.replace(/\r\n/g, '\n'); };

    var getAuthorId = function(Env, curve, uid) {
        return Env.common.getAuthorId(Env.comments.authors, curve, uid);
    };

    // Return the author ID  and add/update user data
    // associate data with a curvePublic for registered users and the uid otherwise
    var updateAuthorData = function(Env, onChange) {
        var userData = Env.metadataMgr.getUserData();
        var myAuthorId;
        if (!Env.common.isLoggedIn()) {
            myAuthorId = getAuthorId(Env, undefined, userData.uid);
        } else {
            myAuthorId = getAuthorId(Env, userData.curvePublic);
        }

        var data = Env.comments.authors[myAuthorId] = Env.comments.authors[myAuthorId] || {};
        var old = Sortify(data);
        data.name = userData.name;
        data.avatar = userData.avatar;
        data.profile = userData.profile;
        data.curvePublic = userData.curvePublic;
        data.notifications = userData.notifications;
        data.uid = userData.uid;

        if (typeof(onChange) === "function" && Sortify(data) !== old) {
            onChange();
        }
        return myAuthorId;
    };

    var updateMetadata = function(Env) {
        var md = Util.clone(Env.metadataMgr.getMetadata());
        md.comments = Util.clone(Env.comments);
        Env.metadataMgr.updateMetadata(md);
    };

    var sendReplyNotification = function(Env, uid, mentionedCurve) {
        if (!Env.comments || !Env.comments.data || !Env.comments.authors) { return; }
        if (!Env.common.isLoggedIn()) { return; }
        var thread = Env.comments.data[uid];
        if (!thread || !Array.isArray(thread.m)) { return; }
        var userData = Env.metadataMgr.getUserData();
        var privateData = Env.metadataMgr.getPrivateData();
        var others = {};

        // Get all the other registered users with a mailbox
        thread.m.forEach(function(obj) {
            var u = obj.u;
            if (typeof(u) !== "number") { return; }
            var author = Env.comments.authors[u];
            if (!author || others[u] || !author.notifications || !author.curvePublic) { return; }
            if (author.curvePublic === userData.curvePublic) { return; } // don't send to yourself
            if (Object.keys(mentionedCurve || {}).includes(author.curvePublic)) {
                return; // Don't send to mentioned users
            }
            others[u] = {
                curvePublic: author.curvePublic,
                comment: obj.m,
                content: obj.v,
                notifications: author.notifications,
                uid: author.uid,
            };
        });
        // Send the notification
        Object.keys(others).forEach(function(id) {
            var data = others[id];
            Env.common.mailbox.sendTo("COMMENT_REPLY", {
                channel: privateData.channel,
                comment: data.comment.replace(/<[^>]*>/g, ''),
                content: data.content
            }, {
                channel: data.notifications,
                curvePublic: data.curvePublic
            });
        });

    };

    var cleanMentions = function($el) {
        $el.html('');
        var el = $el[0];
        var allowed = ['data-profile', 'data-name', 'data-avatar', 'class'];
        // Remove unnecessary/unsafe attributes
        for (var i = el.attributes.length - 1; i > 0; i--) {
            var name = el.attributes[i] && el.attributes[i].name;
            if (allowed.indexOf(name) === -1) {
                $el.removeAttr(name);
            }
        }
    };

    // Seletc all text of a contenteditable element
    var selectAll = function(element) {
        var selection = window.getSelection();
        var range = document.createRange();
        range.selectNodeContents(element);
        selection.removeAllRanges();
        selection.addRange(range);
    };

    var getCommentForm = function(Env, reply, _cb, editContent) {
        var cb = Util.once(_cb);
        var userData = Env.metadataMgr.getUserData();
        var name = Util.fixHTML(userData.name || Messages.anonymous);
        var avatar = h('span.cp-avatar');
        var textarea = h('div.cp-textarea', {
            tabindex: 1,
            role: 'textbox',
            'aria-multiline': true,
            'aria-labelledby': 'cp-comments-label',
            'aria-required': true,
            contenteditable: true,
        });
        Env.common.displayAvatar($(avatar), userData.avatar, name, Util.noop, userData.uid);

        var cancel = h('button.btn.btn-cancel', {
            tabindex: 1
        }, [
            h('i.fa.fa-times'),
            Messages.cancel
        ]);
        var submit = h('button.btn.btn-primary', {
            tabindex: 1
        }, [
            h('i.fa.fa-paper-plane-o'),
            Messages.comments_submit
        ]);

        // List of allowed attributes in mentions
        $(submit).click(function(e) {
            e.stopPropagation();
            var clone = textarea.cloneNode(true);
            var notify = {};
            var $clone = $(clone);
            $clone.find('span.cp-mentions').each(function(i, el) {
                var $el = $(el);
                var curve = $el.attr('data-curve');
                var notif = $el.attr('data-notifications');
                cleanMentions($el, true);
                if (!curve || !notif) { return; }
                notify[curve] = notif;
            });
            $clone.find('br').replaceWith("\n");
            $clone.find('> *:not(.cp-mentions)').remove();
            var content = clone.innerHTML.trim();
            if (!content) { return; }

            // Send notification
            var privateData = Env.metadataMgr.getPrivateData();
            var userData = Env.metadataMgr.getUserData();
            Object.keys(notify).forEach(function(curve) {
                if (curve === userData.curvePublic) { return; }
                Env.common.mailbox.sendTo("MENTION", {
                    channel: privateData.channel,
                }, {
                    channel: notify[curve],
                    curvePublic: curve
                });
            });

            // Push the content
            cb(content, notify);
        });
        $(cancel).click(function(e) {
            e.stopPropagation();
            cb();
        });

        var $text = $(textarea).keydown(function(e) {
            e.stopPropagation();
            if (e.which === 27) {
                $(cancel).click();
                e.stopImmediatePropagation();
            }
            if (e.which === 13 && !e.shiftKey) {
                // Submit form on Enter is the autocompelte menu is not visible
                try {
                    var visible = $text.autocomplete("instance").menu.activeMenu.is(':visible');
                    if (visible) { return; }
                } catch (err) {}
                $(submit).click();
                e.stopImmediatePropagation();
                e.preventDefault();
            }
        }).click(function(e) {
            e.stopPropagation();
        });


        if (Env.common.isLoggedIn()) {
            var authors = {};
            Object.keys((Env.comments && Env.comments.authors) ||  {})
            .filter(function (id) { return Util.find(Env, ['commments', 'authors', id, 'curvePublic']); })
            .forEach(function(id) {
                var obj = Util.clone(Env.comments.authors[id]);
                authors[obj.curvePublic] = obj;
            });
            Env.common.addMentions({
                $input: $text,
                contenteditable: true,
                type: 'contacts',
                sources: authors
            });
        }

        var deleteButton;
        // Edit? start with the old content
        // Add a space to make sure we won't end with a mention and a bad cursor
        if (editContent) {
            textarea.innerHTML = editContent + " ";
            deleteButton = h('button.btn.btn-danger', {
                tabindex: 1
            }, [
                h('i.fa.fa-times'),
                Messages.kanban_delete
            ]);
            $(deleteButton).click(function(e) {
                e.stopPropagation();
                cb(false);
            });
        }


        setTimeout(function() {
            $(textarea).focus();
            selectAll(textarea);
        });

        return h('div.cp-comment-form' + (reply ? '.cp-comment-reply' : ''), {
            'data-uid': reply || ''
        }, [
            h('div.cp-comment-form-input', [
                avatar,
                textarea
            ]),
            h('div.cp-comment-form-actions', [
                cancel,
                deleteButton,
                submit
            ])
        ]);
    };

    var redrawComments = function(Env) {
        // Don't redraw if there were no change
        var str = Sortify(Env.comments || {});
        if (str === Env.oldComments) { return; }
        Env.oldComments = str;

        // Store the cursor position if it's located in this form
        var oldSelection = window.getSelection();
        var oldRangeObj;
        if ($(oldSelection.anchorNode).closest('.cp-comment-form').length) {
            var oldRange = oldSelection.getRangeAt && oldSelection.getRangeAt(0);
            oldRangeObj = {
                start: oldRange.startContainer,
                startO: oldRange.startOffset,
                end: oldRange.endContainer,
                endO: oldRange.endOffset
            };
        }
        // Store existing input form in memory
        var $oldInput = Env.$container.find('.cp-comment-form').detach();
        if ($oldInput.length !== 1) { $oldInput = undefined; }

        // Remove everything
        Env.$container.html('');
        var hideBtn = h('button.cp-pad-hide.btn.btn-default.fa.fa-chevron-right');
        var showBtn = h('button.cp-pad-show.btn.btn-default', {
            title: Messages.poll_comment_list
        }, [
            h('i.fa.fa-comment')
        ]);


        var store = window.cryptpadStore;
        var key = 'hide-pad-comments';
        $(hideBtn).click(function () {
            Env.$container.addClass('hidden');
            Env.localHide = true;
            if (store) { store.put(key, '1'); }
        });
        var $showBtn = $(showBtn).click(function () {
            Env.$container.removeClass('hidden');
            Env.localHide = false;
            if (store) { store.put(key, '0'); }
        });
        Env.$container.append([
            showBtn,
            hideBtn,
            h('h2', Messages.poll_comment_list)
        ]);

        // "show" tells us if we need to display the "comments" column or not
        var show = false;

        // If we were adding a new comment, redraw our form
        if ($oldInput && !$oldInput.attr('data-uid')) {
            show = true;
            Env.$container.append($oldInput);
        }

        var userData = Env.metadataMgr.getUserData();

        // Get all the comment threads in their order in the pad
        var threads = Env.$inner.find('comment').map(function(i, el) {
            return el.getAttribute('data-uid');
        }).toArray();

        // Draw all comment threads
        Util.deduplicateString(threads).forEach(function(key) {
            // Get thread data
            var obj = Env.comments.data[key];
            if (!obj || obj.d ||  !Array.isArray(obj.m) ||  !obj.m.length) {
                return;
            }

            // If at least one thread is visible, display the "comments" column
            show = true;

            var content = [];
            var $div;
            var $actions;

            // Draw all messages for this thread
            (obj.m || []).forEach(function(msg, i) {
                var replyCls = i === 0 ? '' : '.cp-comment-reply';
                if (msg.d) {

                    content.push(h('div.cp-comment.cp-comment-deleted' + replyCls,
                        Messages.comments_deleted));
                    return;
                }
                var author = typeof(msg.u) === "number" ?
                    ((Env.comments.authors || {})[msg.u] || {}) : { name: msg.u };
                var name = Util.fixHTML(author.name || Messages.anonymous);
                var date = new Date(msg.t);
                var avatar = h('span.cp-avatar');
                Env.common.displayAvatar($(avatar), author.avatar, name, Util.noop, author.uid);
                if (author.profile) {
                    $(avatar).click(function(e) {
                        Env.common.openURL(Hash.hashToHref(author.profile, 'profile'));
                        e.stopPropagation();
                    });
                }

                // Build sanitized html with mentions
                var m = h('div.cp-comment-content');
                m.innerHTML = msg.m;
                var $m = $(m);
                $m.find('> *:not(span.cp-mentions)').remove();
                $m.find('span.cp-mentions').each(function(i, el) {
                    var $el = $(el);
                    var name = $el.attr('data-name');
                    var avatarUrl = $el.attr('data-avatar');
                    var profile = $el.attr('data-profile');
                    if (!name && !avatarUrl && !profile) {
                        $el.remove();
                        return;
                    }
                    cleanMentions($el);
                    var avatar = h('span.cp-avatar');
                    Env.common.displayAvatar($(avatar), avatarUrl, name, Util.noop, author.uid);
                    $el.append([
                        avatar,
                        h('span.cp-mentions-name', name)
                    ]);
                    if (profile) {
                        $el.attr('tabindex', 1);
                        $el.addClass('cp-mentions-clickable').click(function(e) {
                            e.preventDefault();
                            e.stopPropagation();
                            Env.common.openURL(Hash.hashToHref(profile, 'profile'));
                        }).focus(function(e) {
                            e.stopPropagation();
                        });
                    }
                });

                // edited state
                var edited;
                if (msg.e) {
                    edited = h('div.cp-comment-edited', Messages.comments_edited);
                }

                var container;

                // Add edit button when applicable (last message of the thread, written by ourselves)
                var edit;
                if (i === (obj.m.length - 1) && author.curvePublic === userData.curvePublic) {
                    edit = h('span.cp-comment-edit', {
                        tabindex: 1,
                        title: Messages.clickToEdit
                    }, h('i.fa.fa-pencil'));
                    $(edit).click(function(e) {
                        Env.$container.find('.cp-comment-active').removeClass('cp-comment-active');
                        $div.addClass('cp-comment-active');
                        e.stopPropagation();
                        Env.$container.find('.cp-comment-form').remove();
                        if ($actions) { $actions.hide(); }
                        var form = getCommentForm(Env, key, function(val) {
                            // Show the "reply" and "resolve" buttons again
                            $(form).closest('.cp-comment-container')
                                .find('.cp-comment-actions').css('display', '');
                            $(form).remove();

                            if (typeof(val) === "undefined") { return; }

                            var obj = Env.comments.data[key];
                            if (!obj || !Array.isArray(obj.m)) { return; }
                            var msg = obj.m[i];
                            if (!msg) { return; }
                            // i is our index
                            if (val === false) {
                                msg.d = 1;
                                if (container) {
                                    $(container).addClass('cp-comment-deleted')
                                        .html(Messages.comments_deleted);
                                }
                                if (obj.m.length === 1) {
                                    delete Env.comments.data[key];
                                }
                            } else {
                                msg.e = 1;
                                msg.m = val;
                            }

                            // Send to chainpad
                            updateMetadata(Env);
                            Env.framework.localChange();
                        }, m.innerHTML);

                        if (!$div) { return; }
                        $div.append(form);
                    });
                }

                // Add the comment
                content.push(container = h('div.cp-comment' + replyCls, [
                    h('div.cp-comment-header', [
                        avatar,
                        h('span.cp-comment-metadata', [
                            h('span.cp-comment-author', name),
                            h('span.cp-comment-time', date.toLocaleString())
                        ]),
                        edit
                    ]),
                    m,
                    edited
                ]));

            });

            var reply = h('button.btn.btn-secondary', {
                tabindex: 1
            }, [
                h('i.fa.fa-reply'),
                Messages.comments_reply
            ]);
            var resolve = h('button.btn.btn-primary', {
                tabindex: 1
            }, [
                h('i.fa.fa-check'),
                Messages.comments_resolve
            ]);

            var actions;
            content.push(actions = h('div.cp-comment-actions', [
                reply,
                resolve
            ]));
            $actions = $(actions);

            var div;
            Env.$container.append(div = h('div.cp-comment-container', {
                'data-uid': key,
                tabindex: 1
            }, content));
            $div = $(div);

            $(reply).click(function(e) {
                e.stopPropagation();
                $actions.hide();
                var form = getCommentForm(Env, key, function(val, mentioned) {
                    // Show the "reply" and "resolve" buttons again
                    $(form).closest('.cp-comment-container')
                        .find('.cp-comment-actions').css('display', '');
                    $(form).remove();

                    if (!val) { return; }
                    var obj = Env.comments.data[key];
                    if (!obj || !Array.isArray(obj.m)) { return; }

                    // Get the value of the commented text
                    var res = Env.$inner.find('comment[data-uid="' + key + '"]').toArray();
                    var value = res.map(function(el) {
                        return el.innerText;
                    }).join('\n');

                    // Push the reply
                    var user = updateAuthorData(Env);
                    obj.m.push({
                        u: user, // id (number) or name (string)
                        t: +new Date(),
                        m: val,
                        v: value
                    });

                    // Notify other users
                    sendReplyNotification(Env, key, mentioned);

                    // Send to chainpad
                    updateMetadata(Env);
                    Env.framework.localChange();
                });

                $div.append(form);

                // Make sure the submit button is visible: scroll by the height of the form
                setTimeout(function() {
                    var yContainer = Env.$container[0].getBoundingClientRect().bottom;
                    var yActions = form.getBoundingClientRect().bottom;
                    if (yActions > yContainer) {
                        Env.$container.scrollTop(Env.$container.scrollTop() + 55);
                    }
                });
            });

            UI.confirmButton(resolve, {
                classes: 'btn-danger'
            }, function() {
                // Delete the comment
                delete Env.comments.data[key];

                // Send to chainpad
                updateMetadata(Env);
                Env.framework.localChange();
            });

            var focusContent = function() {
                // Add class "active"
                Env.$inner.find('comment.active').removeClass('active');
                Env.$inner.find('comment[data-uid="' + key + '"]').addClass('active');
                var $last = Env.$inner.find('comment[data-uid="' + key + '"]').last();

                // Scroll into view
                if (!$last.length) { return; }
                var visible = UIElements.isVisible($last[0], Env.$contentContainer);
                if (!visible) { $last[0].scrollIntoView(); }
            };

            $div.on('click focus', function(e) {
                // Prevent the click event to propagate if we're already selected
                // The propagation to #cp-app-pad-inner would trigger the "unselect" handler
                e.stopPropagation();
                if ($div.hasClass('cp-comment-active')) { return; }
                Env.$container.find('.cp-comment-active').removeClass('cp-comment-active');
                $div.addClass('cp-comment-active');
                $actions.css('display', '');
                Env.$container.find('.cp-comment-form').remove();

                focusContent();

                var visible = UIElements.isVisible(div, Env.$container);
                if (!visible) { div.scrollIntoView(); }
            });

            if ($oldInput && $oldInput.attr('data-uid') === key) {
                $div.addClass('cp-comment-active');
                $actions.hide();
                $div.append($oldInput);
                $oldInput.find('textarea').focus();
                focusContent();
            }
        });

        // Restore selection
        if (oldRangeObj) {
            setTimeout(function() {
                if (!oldRangeObj) { return; }
                var range = document.createRange();
                range.setStart(oldRangeObj.start, oldRangeObj.startO);
                range.setEnd(oldRangeObj.end, oldRangeObj.endO);
                var sel = window.getSelection();
                sel.removeAllRanges();
                sel.addRange(range);
            });
        }


        // Hidden or visible? check pad settings first, then browser otherwise hide
        var md = Util.clone(Env.metadataMgr.getMetadata());
        var hide = false;
        if (typeof(md.defaultComments) === "undefined") {
            if (typeof(store.store[key]) === 'undefined') {
                hide = !show; // Hide if there are no comments
            } else {
                hide = store.store[key] === '1';
            }
        } else {
            hide = md.defaultComments === 0;
        }
        // If we've clicked on the show/hide buttons, always use our latest local value
        if (typeof(Env.localHide) === "boolean") { hide = Env.localHide; }

        if (Env.mobile) { hide = false; }

        Env.$container.removeClass('hidden');
        if (hide) { Env.$container.addClass('hidden'); }

        $showBtn.removeClass('notif');
        if (show) {
            $showBtn.addClass('notif');
        }

        if (Env.mobile && Env.current) {
            Env.$container.find('.cp-comment-container[data-uid]').hide();
            Env.$container.find('.cp-comment-container[data-uid="' + Env.current + '"]').show();
        }

        Env.$container.show();
    };

    var onChange = function(Env) {
        var md = Util.clone(Env.metadataMgr.getMetadata());
        Env.comments = md.comments;
        var changed = false;
        if (!Env.comments || !Env.comments.data) {
            changed = true;
            Env.comments = Util.clone(COMMENTS);
        }
        if (Env.ready === 0) {
            Env.ready = true;
            updateAuthorData(Env, function() {
                changed = true;
            });
            // On ready, if our user data have changed or if we've added the initial structure
            // of the comments, push the changes
            if (changed) {
                updateMetadata(Env);
                Env.framework.localChange();
            }
        } else if (Env.ready) {
            // Everytime there is a metadata change, check if our user data have changed
            // and push the updates if necessary
            updateAuthorData(Env, function() {
                updateMetadata(Env);
                Env.framework.localChange();
            });
        }
        redrawComments(Env);
    };

    // Check if comments have been deleted from the document but not from metadata
    var checkDeleted = function(Env) {
        if (!Env.comments || !Env.comments.data) { return; }

        // Don't recheck if there were no change
        var str = Env.$inner[0].innerHTML;
        if (str === Env.oldCheck) { return; }
        Env.oldCheck = str;

        // If there is no comment stored in the metadata, abort
        var comments = Object.keys(Env.comments.data || {}).filter(function(id) {
            return !Env.comments.data[id].d;
        });

        var changed = false;

        // Get the comments from the document
        var toUncomment = {};
        var uids = Env.$inner.find('comment').map(function(i, el) {
            var id = el.getAttribute('data-uid');
            // Empty comment: remove from dom
            if (!el.innerHTML && el.parentElement) {
                el.parentElement.removeChild(el);
                changed = true;
                return;
            }
            // Comment not in the metadata: uncomment (probably an undo)
            var obj = Env.comments.data[id];
            if (!obj) {
                toUncomment[id] = toUncomment[id] || [];
                toUncomment[id].push(el);
                changed = true;
                return;
            }
            // If this comment was deleted, we're probably using "undo" to restore it:
            // remove the "deleted" state and continue
            if (obj.d) {
                delete obj.d;
                changed = true;
            }
            return id;
        }).toArray();

        if (Object.keys(toUncomment).length) {
            Object.keys(toUncomment).forEach(function(id) {
                Env.editor.plugins.comments.uncomment(id, toUncomment[id]);
            });
        }

        // Check if a comment has been deleted
        comments.forEach(function(uid) {
            if (uids.indexOf(uid) !== -1) { return; }
            // comment has been deleted
            var data = Env.comments.data[uid];
            if (!data) { return; }
            data.d = 1;
            //delete Env.comments.data[uid];
            changed = true;
        });

        if (changed) {
            updateMetadata(Env);
        }
    };

    var removeCommentBubble = function(Env) {
        Env.bubble = undefined;
        Env.$contentContainer.find('.cp-comment-bubble').remove();
    };
    var updateBubble = function(Env) {
        if (!Env.bubble) { return; }
        var pos = Env.bubble.range.getClientRects()[0];
        var left = pos.x + pos.width;
        Env.bubble.button.setAttribute('style', 'top:' + pos.y + 'px; left: '+left+'px');
    };
    var addCommentBubble = function(Env) {
        var ranges = Env.editor.getSelectedRanges();
        if (!ranges.length) { return; }

        var button = h('button.btn.btn-secondary', {
            title: Messages.comments_comment
        }, h('i.fa.fa-commenting'));
        Env.bubble = {
            range: ranges[ranges.length-1],
            button: button
        };
        $(button).click(function(e)  {
            e.stopPropagation();
            Env.editor.execCommand('comment');
            Env.bubble = undefined;
            removeCommentBubble(Env);
        });
        Env.$contentContainer.find('iframe').before(h('div.cp-comment-bubble', button));
        updateBubble(Env);
    };

    var isEditable = function (document) {
        try {
            return document.body.getAttribute('contenteditable') === 'true';
        } catch (err) {
            return false;
        }
    };

    var addAddCommentHandler = function(Env) {
        Env.editor.plugins.comments.addComment = function(uid, addMark) {
            if (!Env.ready) { return; }
            if (!Env.comments) { Env.comments = Util.clone(COMMENTS); }

            // Get all comments ID contained within the selection
            var applicable = Env.editor.plugins.comments.isApplicable();
            if (!applicable || !isEditable(Env.ifrWindow.document)) {
                // Abort if our selection contains a comment
                UI.warn(Messages.comments_error);
                return;
            }

            // Remove active class on other comments
            Env.$container.find('.cp-comment-active').removeClass('cp-comment-active');
            Env.$container.find('.cp-comment-form').remove();
            Env.$container.removeClass('hidden');
            Env.localHide = false;
            var form = getCommentForm(Env, false, function(val) {
                $(form).remove();
                Env.$inner.focus();

                if (!val) { addCommentBubble(Env); return; }
                var applicable = Env.editor.plugins.comments.isApplicable();
                if (!applicable || !isEditable(Env.ifrWindow.document)) {
                    // text has been deleted by another user while we were typing our comment?
                    return void UI.warn(Messages.error);
                }

                // Don't override existing data
                if (Env.comments.data[uid]) { return; }

                var user = updateAuthorData(Env);
                Env.comments.data[uid] = {
                    m: [{
                        u: user, // Id or name
                        t: +new Date(),
                        m: val,
                        v: canonicalize(Env.editor.getSelection().getSelectedText())
                    }]
                };

                Env.current = uid;

                // There may be a race condition between updateMetadata and addMark that causes
                //  * updateMetadata first:  comment not rendered (redrawComments called
                //                           before addMark)
                //  * addMark first: comment deleted (checkDeleted called before updateMetadata)
                // ==> we're going to call updateMetadata first, and we'll invalidate the cache
                //     of rendered comments to display them properly in redrawComments
                updateMetadata(Env);
                addMark();

                Env.framework.localChange();

                Env.oldComments = undefined;
            });

            Env.$container.show();
            Env.$container.find('> h2').after(form);

            if (Env.modal) {
                UI.openCustomModal(Env.modal);
                Env.current = undefined;
                Env.$container.find('.cp-comment-container[data-uid]').hide();
            }
        };


        Env.$iframe.on('scroll', function() {
            updateBubble(Env);
        });
        $(Env.ifrWindow.document).on('selectionchange', function() {
            removeCommentBubble(Env);
            var comments = Env.editor.plugins.comments;
            var applicable = comments.isApplicable();
            if (!applicable || !isEditable(Env.ifrWindow.document)) {
                return void comments.command.setState(0);
            }
            addCommentBubble(Env);
            comments.command.setState(2);
        });
    };

    var onContentUpdate = function(Env) {
        if (!Env.ready) { return; }
        // Check deleted
        onChange(Env);
        checkDeleted(Env);
    };

    var ready = function(Env) {
        Env.ready = 0;

        onChange(Env);

        // If you're the only edit user online, clear "deleted" comments
        if (!Env.common.isLoggedIn()) { return; }
        var users = Env.metadataMgr.getMetadata().users || {};
        var isNotAlone = Object.keys(users).length > 1;
        if (isNotAlone) { return; }

        // Clear data
        var data = (Env.comments && Env.comments.data) || {};
        Object.keys(data).forEach(function(uid) {
            if (data[uid].d) { delete data[uid]; }
        });

        // Commit
        updateMetadata(Env);
        Env.framework.localChange();
    };

    Comments.create = function(cfg) {
        var Env = cfg;
        Env.comments = Util.clone(COMMENTS);

        // Add invisible label for accessibility tools
        var label = h('label#cp-comments-label', {
            style: "display:none;"
        }, Messages.comments_comment);
        Env.$container.before(label);

        var ro = cfg.framework.isReadOnly();
        var onEditableChange = function(unlocked) {
            Env.$container.removeClass('cp-comments-readonly');
            if (ro || !unlocked) {
                Env.$container.addClass('cp-comments-readonly');
            }
        };
        cfg.framework.onEditableChange(onEditableChange);
        onEditableChange();

        addAddCommentHandler(Env);

        // Unselect comment when clicking outside
        $(window).click(function(e) {
            var $target = $(e.target);
            if (!$target.length) { return; }
            if ($target.is('.cp-comment-container')) { return; }
            if ($target.closest('.cp-comment-container').length) { return; }
            if ($target.closest('.ui-autocomplete').length) { return; }
            // Add comment button? don't remove anything because this handler is called after
            // the button action
            if ($target.is('.cke_button__comment')) { return; }
            if ($target.closest('.cke_button__comment').length) { return; }
            Env.$container.find('.cp-comment-active').removeClass('cp-comment-active');
            Env.$inner.find('comment.active').removeClass('active');
            Env.$container.find('.cp-comment-form').remove();
        });
        // Unselect comment when clicking on another part of the doc
        Env.$inner.on('click', function(e) {
            if ($(e.target).closest('comment').length) { return; }
            Env.$container.find('.cp-comment-active').removeClass('cp-comment-active');
            Env.$inner.find('comment.active').removeClass('active');
            Env.$container.find('.cp-comment-form').remove();
        });
        Env.$inner.on('click', 'comment', function(e) {
            var $comment = $(e.target);
            var uid = $comment.attr('data-uid');
            if (!uid) { return; }
            if (Env.modal) {
                UI.openCustomModal(Env.modal);
                Env.current = uid;
                Env.$container.find('.cp-comment-container[data-uid]').hide();
                setTimeout(function () {
                Env.$container.find('.cp-comment-container[data-uid="' + uid + '"]').show().click();
                });
            } else {
                Env.$container.find('.cp-comment-container[data-uid="' + uid + '"]').click();
            }
        });

        var call = function(f) {
            return function() {
                try {
                    [].unshift.call(arguments, Env);
                    return f.apply(null, arguments);
                } catch (e) {
                    console.error(e);
                }
            };
        };

        Env.metadataMgr.onChange(call(onChange));

        return {
            onContentUpdate: call(onContentUpdate),
            ready: call(ready)
        };
    };

    return Comments;
});
