// Converse.js (A browser based XMPP chat client)
// http://conversejs.org
//
// Copyright (c) 2012-2017, Jan-Carel Brand <jc@opkode.com>
// Licensed under the Mozilla Public License (MPLv2)
//
/*global define */

(function (root, factory) {
    define(["converse-core", "backbone.overview"], factory);
}(this, function (converse) {
    "use strict";
    const { Backbone, Promise, Strophe, b64_sha1, moment, utils, _ } = converse.env;

    converse.plugins.add('converse-chatboxes', {

        overrides: {
            // Overrides mentioned here will be picked up by converse.js's
            // plugin architecture they will replace existing methods on the
            // relevant objects or classes.

            disconnect: function () {
                const { _converse } = this.__super__;
                _converse.chatboxviews.closeAllChatBoxes();
                return this.__super__.disconnect.apply(this, arguments);
            },

            logOut: function () {
                const { _converse } = this.__super__;
                _converse.chatboxviews.closeAllChatBoxes();
                return this.__super__.logOut.apply(this, arguments);
            },

            initStatus: function () {
                const { _converse } = this.__super__;
                _converse.chatboxviews.closeAllChatBoxes();
                return this.__super__.initStatus.apply(this, arguments);
            },

            onStatusInitialized: function () {
                const { _converse } = this.__super__;
                _converse.chatboxes.onConnected();
                return this.__super__.onStatusInitialized.apply(this, arguments);
            }
        },

        initialize () {
            /* The initialize function gets called as soon as the plugin is
             * loaded by converse.js's plugin machinery.
             */
            const { _converse } = this;

            _converse.api.promises.add([
                'chatBoxesFetched',
                'chatBoxesInitialized'
            ]);

            function openChat (jid) {
                if (!utils.isValidJID(jid)) {
                    return converse.log(
                        `Invalid JID "${jid}" provided in URL fragment`,
                        Strophe.LogLevel.WARN
                    );
                }
                Promise.all([
                    _converse.api.waitUntil('rosterContactsFetched'),
                    _converse.api.waitUntil('chatBoxesFetched')
                ]).then(() => {
                    _converse.api.chats.open(jid);
                });
            }
            _converse.router.route('converse/chat?jid=:jid', openChat);


            _converse.Message = Backbone.Model.extend({
                defaults(){
                    return {
                        msgid: _converse.connection.getUniqueId()
                    };
                }
            });


            _converse.Messages = Backbone.Collection.extend({
                model: _converse.Message,
                comparator: 'time'
            });


            _converse.ChatBox = Backbone.Model.extend({
                defaults: {
                    'type': 'chatbox',
                    'show_avatar': true,
                    'bookmarked': false,
                    'chat_state': undefined,
                    'num_unread': 0,
                    'url': ''
                },

                initialize () {
                    this.messages = new _converse.Messages();
                    this.messages.browserStorage = new Backbone.BrowserStorage[_converse.message_storage](
                        b64_sha1(`converse.messages${this.get('jid')}${_converse.bare_jid}`));
                    this.save({
                        // The chat_state will be set to ACTIVE once the chat box is opened
                        // and we listen for change:chat_state, so shouldn't set it to ACTIVE here.
                        'box_id' : b64_sha1(this.get('jid')),
                        'time_opened': this.get('time_opened') || moment().valueOf(),
                        'user_id' : Strophe.getNodeFromJid(this.get('jid'))
                    });
                },

                getMessageBody (message) {
                    const type = message.getAttribute('type');
                    return (type === 'error') ?
                        _.propertyOf(message.querySelector('error text'))('textContent') :
                            _.propertyOf(message.querySelector('body'))('textContent');
                },

                getMessageAttributes (message, delay, original_stanza) {
                    /* Parses a passed in message stanza and returns an object
                     * of attributes.
                     *
                     * Parameters:
                     *    (XMLElement) message - The message stanza
                     *    (XMLElement) delay - The <delay> node from the
                     *      stanza, if there was one.
                     *    (XMLElement) original_stanza - The original stanza,
                     *      that contains the message stanza, if it was
                     *      contained, otherwise it's the message stanza itself.
                     */
                    const { _converse } = this.__super__,
                          { __ } = _converse;

                    delay = delay || message.querySelector('delay');
                    const type = message.getAttribute('type'),
                        body = this.getMessageBody(message);

                    const delayed = !_.isNull(delay),
                        is_groupchat = type === 'groupchat',
                        chat_state = message.getElementsByTagName(_converse.COMPOSING).length && _converse.COMPOSING ||
                            message.getElementsByTagName(_converse.PAUSED).length && _converse.PAUSED ||
                            message.getElementsByTagName(_converse.INACTIVE).length && _converse.INACTIVE ||
                            message.getElementsByTagName(_converse.ACTIVE).length && _converse.ACTIVE ||
                            message.getElementsByTagName(_converse.GONE).length && _converse.GONE;

                    let from;
                    if (is_groupchat) {
                        from = Strophe.unescapeNode(Strophe.getResourceFromJid(message.getAttribute('from')));
                    } else {
                        from = Strophe.getBareJidFromJid(message.getAttribute('from'));
                    }
                    const time = delayed ? delay.getAttribute('stamp') : moment().format();
                    let sender, fullname;
                    if ((is_groupchat && from === this.get('nick')) || (!is_groupchat && from === _converse.bare_jid)) {
                        sender = 'me';
                        fullname = _converse.xmppstatus.get('fullname') || from;
                    } else {
                        sender = 'them';
                        fullname = this.get('fullname') || from;
                    }
                    const spoiler = message.querySelector(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`);
                    const attrs = {
                        'type': type,
                        'chat_state': chat_state,
                        'delayed': delayed,
                        'fullname': fullname,
                        'message': body || undefined,
                        'msgid': message.getAttribute('id'),
                        'sender': sender,
                        'time': time,
                        'is_spoiler': !_.isNull(spoiler)
                    };
                    if (spoiler) {
                        attrs.spoiler_hint = spoiler.textContent.length > 0 ? spoiler.textContent : '';
                    }
                    return attrs;
                },

                createMessage (message, delay, original_stanza) {
                    /* Create a Backbone.Message object inside this chat box
                     * based on the identified message stanza.
                     */
                    return this.messages.create(this.getMessageAttributes.apply(this, arguments));
                },

                newMessageWillBeHidden () {
                    /* Returns a boolean to indicate whether a newly received
                     * message will be visible to the user or not.
                     */
                    return this.get('hidden') ||
                        this.get('minimized') ||
                        this.isScrolledUp() ||
                        _converse.windowState === 'hidden';
                },

                incrementUnreadMsgCounter (stanza) {
                    /* Given a newly received message, update the unread counter if
                     * necessary.
                     */
                    if (_.isNull(stanza.querySelector('body'))) {
                        return; // The message has no text
                    }
                    if (utils.isNewMessage(stanza) && this.newMessageWillBeHidden()) {
                        this.save({'num_unread': this.get('num_unread') + 1});
                        _converse.incrementMsgCounter();
                    }
                },

                clearUnreadMsgCounter() {
                    this.save({'num_unread': 0});
                },

                isScrolledUp () {
                    return this.get('scrolled', true);
                }
            });


            _converse.ChatBoxes = Backbone.Collection.extend({
                comparator: 'time_opened',

                model (attrs, options) {
                    return new _converse.ChatBox(attrs, options);
                },

                registerMessageHandler () {
                    _converse.connection.addHandler(
                        this.onMessage.bind(this), null, 'message', 'chat'
                    );
                    _converse.connection.addHandler(
                        this.onErrorMessage.bind(this), null, 'message', 'error'
                    );
                },

                chatBoxMayBeShown (chatbox) {
                    return true;
                },

                onChatBoxesFetched (collection) {
                    /* Show chat boxes upon receiving them from sessionStorage
                    *
                    * This method gets overridden entirely in src/converse-controlbox.js
                    * if the controlbox plugin is active.
                    */
                    collection.each((chatbox) => {
                        if (this.chatBoxMayBeShown(chatbox)) {
                            chatbox.trigger('show');
                        }
                    });
                    _converse.emit('chatBoxesFetched');
                },

                onConnected () {
                    this.browserStorage = new Backbone.BrowserStorage[_converse.storage](
                        b64_sha1(`converse.chatboxes-${_converse.bare_jid}`));
                    this.registerMessageHandler();
                    this.fetch({
                        add: true,
                        success: this.onChatBoxesFetched.bind(this)
                    });
                },

                onErrorMessage (message) {
                    /* Handler method for all incoming error message stanzas
                    */
                    // TODO: we can likely just reuse "onMessage" below
                    const from_jid =  Strophe.getBareJidFromJid(message.getAttribute('from'));
                    if (utils.isSameBareJID(from_jid, _converse.bare_jid)) {
                        return true;
                    }
                    // Get chat box, but only create a new one when the message has a body.
                    const chatbox = this.getChatBox(from_jid);
                    if (!chatbox) {
                        return true;
                    }
                    chatbox.createMessage(message, null, message);
                    return true;
                },

                onMessage (message) {
                    /* Handler method for all incoming single-user chat "message"
                     * stanzas.
                     *
                     * Parameters:
                     *    (XMLElement) message - The incoming message stanza
                     */
                    let contact_jid, delay, resource,
                        from_jid = message.getAttribute('from'),
                        to_jid = message.getAttribute('to');

                    const original_stanza = message,
                        to_resource = Strophe.getResourceFromJid(to_jid),
                        is_carbon = !_.isNull(message.querySelector(`received[xmlns="${Strophe.NS.CARBONS}"]`));

                    if (_converse.filter_by_resource && (to_resource && to_resource !== _converse.resource)) {
                        _converse.log(
                            `onMessage: Ignoring incoming message intended for a different resource: ${to_jid}`,
                            Strophe.LogLevel.INFO
                        );
                        return true;
                    } else if (utils.isHeadlineMessage(_converse, message)) {
                        // XXX: Ideally we wouldn't have to check for headline
                        // messages, but Prosody sends headline messages with the
                        // wrong type ('chat'), so we need to filter them out here.
                        _converse.log(
                            `onMessage: Ignoring incoming headline message sent with type 'chat' from JID: ${from_jid}`,
                            Strophe.LogLevel.INFO
                        );
                        return true;
                    }
                    const forwarded = message.querySelector('forwarded');
                    if (!_.isNull(forwarded)) {
                        const forwarded_message = forwarded.querySelector('message');
                        const forwarded_from = forwarded_message.getAttribute('from');
                        if (is_carbon && Strophe.getBareJidFromJid(forwarded_from) !== from_jid) {
                            // Prevent message forging via carbons
                            //
                            // https://xmpp.org/extensions/xep-0280.html#security
                            return true;
                        }
                        message = forwarded_message;
                        delay = forwarded.querySelector('delay');
                        from_jid = message.getAttribute('from');
                        to_jid = message.getAttribute('to');
                    }

                    const from_bare_jid = Strophe.getBareJidFromJid(from_jid),
                        from_resource = Strophe.getResourceFromJid(from_jid),
                        is_me = from_bare_jid === _converse.bare_jid;

                    if (is_me) {
                        // I am the sender, so this must be a forwarded message...
                        contact_jid = Strophe.getBareJidFromJid(to_jid);
                        resource = Strophe.getResourceFromJid(to_jid);
                    } else {
                        contact_jid = from_bare_jid;
                        resource = from_resource;
                    }
                    // Get chat box, but only create a new one when the message has a body.
                    const chatbox = this.getChatBox(contact_jid, !_.isNull(message.querySelector('body'))),
                        msgid = message.getAttribute('id');

                    if (chatbox) {
                        const messages = msgid && chatbox.messages.findWhere({msgid}) || [];
                        if (_.isEmpty(messages)) {
                            // Only create the message when we're sure it's not a
                            // duplicate
                            chatbox.incrementUnreadMsgCounter(original_stanza);
                            chatbox.createMessage(message, delay, original_stanza);
                        }
                    }
                    _converse.emit('message', {'stanza': original_stanza, 'chatbox': chatbox});
                    return true;
                },

                createChatBox (jid, attrs) {
                    /* Creates a chat box
                    *
                    * Parameters:
                    *    (String) jid - The JID of the user for whom a chat box
                    *      gets created.
                    *    (Object) attrs - Optional chat box atributes.
                    */
                    const bare_jid = Strophe.getBareJidFromJid(jid),
                        roster_item = _converse.roster.get(bare_jid);
                    let roster_info = {};

                    if (! _.isUndefined(roster_item)) {
                        roster_info = {
                            'fullname': _.isEmpty(roster_item.get('fullname'))? jid: roster_item.get('fullname'),
                            'image_type': roster_item.get('image_type'),
                            'image': roster_item.get('image'),
                            'url': roster_item.get('url'),
                        };
                    } else if (!_converse.allow_non_roster_messaging) {
                        _converse.log(`Could not get roster item for JID ${bare_jid}`+
                            ' and allow_non_roster_messaging is set to false',
                            Strophe.LogLevel.ERROR);
                        return;
                    }
                    return this.create(_.assignIn({
                            'id': bare_jid,
                            'jid': bare_jid,
                            'fullname': jid,
                            'image_type': _converse.DEFAULT_IMAGE_TYPE,
                            'image': _converse.DEFAULT_IMAGE,
                            'url': '',
                        }, roster_info, attrs || {}));
                },

                getChatBox (jid, create, attrs) {
                    /* Returns a chat box or optionally return a newly
                    * created one if one doesn't exist.
                    *
                    * Parameters:
                    *    (String) jid - The JID of the user whose chat box we want
                    *    (Boolean) create - Should a new chat box be created if none exists?
                    *    (Object) attrs - Optional chat box atributes.
                    */
                    jid = jid.toLowerCase();
                    let  chatbox = this.get(Strophe.getBareJidFromJid(jid));
                    if (!chatbox && create) {
                        chatbox = this.createChatBox(jid, attrs);
                    }
                    return chatbox;
                }
            });

            _converse.ChatBoxViews = Backbone.Overview.extend({

                initialize () {
                    this.model.on("add", this.onChatBoxAdded, this);
                    this.model.on("destroy", this.removeChat, this);
                },

                _ensureElement () {
                    /* Override method from backbone.js
                    * If the #conversejs element doesn't exist, create it.
                    */
                    if (!this.el) {
                        let el = _converse.root.querySelector('#conversejs');
                        if (_.isNull(el)) {
                            el = document.createElement('div');
                            el.setAttribute('id', 'conversejs');
                            const body = _converse.root.querySelector('body');
                            if (body) {
                                body.appendChild(el);
                            } else {
                                // Perhaps inside a web component?
                                _converse.root.appendChild(el);
                            }
                        }
                        el.innerHTML = '';
                        this.setElement(el, false);
                    } else {
                        this.setElement(_.result(this, 'el'), false);
                    }
                },

                onChatBoxAdded (item) {
                    // Views aren't created here, since the core code doesn't
                    // contain any views. Instead, they're created in overrides in
                    // plugins, such as in converse-chatview.js and converse-muc.js
                    return this.get(item.get('id'));
                },

                removeChat (item) {
                    this.remove(item.get('id'));
                },

                closeAllChatBoxes () {
                    /* This method gets overridden in src/converse-controlbox.js if
                    * the controlbox plugin is active.
                    */
                    this.each(function (view) { view.close(); });
                    return this;
                },

                chatBoxMayBeShown (chatbox) {
                    return this.model.chatBoxMayBeShown(chatbox);
                },

                getChatBox (attrs, create) {
                    let chatbox  = this.model.get(attrs.jid);
                    if (!chatbox && create) {
                        chatbox = this.model.create(attrs, {
                            'error' (model, response) {
                                _converse.log(response.responseText);
                            }
                        });
                    }
                    return chatbox;
                },

                showChat (attrs) {
                    /* Find the chat box and show it (if it may be shown).
                     * If it doesn't exist, create it.
                     */
                    const chatbox = this.getChatBox(attrs, true);
                    if (this.chatBoxMayBeShown(chatbox)) {
                        chatbox.trigger('show', true);
                    }
                    return chatbox;
                }
            });

            // BEGIN: Event handlers
            _converse.api.listen.on('pluginsInitialized', () => {
                _converse.chatboxes = new _converse.ChatBoxes();
                _converse.chatboxviews = new _converse.ChatBoxViews({
                    'model': _converse.chatboxes
                });
                _converse.emit('chatBoxesInitialized');
            });

            _converse.api.listen.on('beforeTearDown', () => {
                _converse.chatboxes.remove(); // Don't call off(), events won't get re-registered upon reconnect.
                delete _converse.chatboxes.browserStorage;
            });
            // END: Event handlers

            _converse.getViewForChatBox = function (chatbox) {
                if (!chatbox) { return; }
                return _converse.chatboxviews.get(chatbox.get('id'));
            };

            /* We extend the default converse.js API */
            _.extend(_converse.api, {
                'chats': {
                    'open' (jids, attrs) {
                        if (_.isUndefined(jids)) {
                            _converse.log("chats.open: You need to provide at least one JID", Strophe.LogLevel.ERROR);
                            return null;
                        } else if (_.isString(jids)) {
                            const chatbox = _converse.chatboxes.getChatBox(jids, true, attrs);
                            if (_.isNil(chatbox)) {
                                _converse.log("Could not open chatbox for JID: "+jids);
                                return;
                            }
                            return _converse.getViewForChatBox(chatbox.trigger('show'));
                        }
                        return _.map(jids, (jid) =>
                            _converse.getViewForChatBox(
                                _converse.chatboxes.getChatBox(jid, true, attrs).trigger('show')
                            )
                        );
                    },
                    'get' (jids) {
                        if (_.isUndefined(jids)) {
                            const result = [];
                            _converse.chatboxes.each(function (chatbox) {
                                // FIXME: Leaky abstraction from MUC. We need to add a
                                // base type for chat boxes, and check for that.
                                if (chatbox.get('type') !== 'chatroom') {
                                    result.push(_converse.getViewForChatBox(chatbox));
                                }
                            });
                            return result;
                        } else if (_.isString(jids)) {
                            return _converse.getViewForChatBox(_converse.chatboxes.getChatBox(jids));
                        }
                        return _.map(jids,
                            _.partial(
                                _.flow(
                                    _converse.chatboxes.getChatBox.bind(_converse.chatboxes),
                                    _converse.getViewForChatBox.bind(_converse)
                                ), _, true
                            )
                        );
                    }
                }
            });
        }
    });
    return converse;
}));
