/*
 * Jitsi, the OpenSource Java VoIP and Instant Messaging client.
 *
 * Copyright @ 2015 Atlassian Pty Ltd
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package net.java.sip.communicator.impl.gui.main.chat;

import java.awt.*;
import java.awt.datatransfer.*;
import java.awt.event.*;
import java.io.*;
import java.net.*;
import java.text.*;
import java.util.*;
import java.util.Map;
import java.util.regex.*;

import javax.swing.*;
import javax.swing.event.*;
import javax.swing.text.*;
import javax.swing.text.html.*;
import javax.swing.text.html.HTML.Attribute;

import net.java.sip.communicator.impl.gui.*;
import net.java.sip.communicator.impl.gui.main.chat.history.*;
import net.java.sip.communicator.impl.gui.main.chat.menus.*;
import net.java.sip.communicator.impl.gui.main.chat.replacers.*;
import net.java.sip.communicator.impl.gui.utils.*;
import net.java.sip.communicator.impl.gui.utils.Constants;
import net.java.sip.communicator.plugin.desktoputil.*;
import net.java.sip.communicator.plugin.desktoputil.SwingWorker;
import net.java.sip.communicator.service.gui.*;
import net.java.sip.communicator.service.history.*;
import net.java.sip.communicator.service.protocol.*;
import net.java.sip.communicator.service.replacement.*;
import net.java.sip.communicator.service.replacement.directimage.*;
import net.java.sip.communicator.service.replacement.smilies.*;
import net.java.sip.communicator.util.*;
import net.java.sip.communicator.util.Logger;
import net.java.sip.communicator.util.skin.*;

import org.apache.commons.lang3.*;
import org.jitsi.service.configuration.*;
import org.jitsi.service.fileaccess.*;
import org.osgi.framework.*;

/**
 * The <tt>ChatConversationPanel</tt> is the panel, where all sent and received
 * messages appear. All data is stored in an HTML document. An external CSS file
 * is applied to the document to provide the look&feel. All smileys and link
 * strings are processed and finally replaced by corresponding images and HTML
 * links.
 *
 * @author Yana Stamcheva
 * @author Lyubomir Marinov
 * @author Adam Netocny
 * @author Danny van Heumen
 */
public class ChatConversationPanel
    extends SIPCommScrollPane
    implements  HyperlinkListener,
                MouseListener,
                ClipboardOwner,
                Skinnable
{
    /**
     * The <tt>Logger</tt> used by the <tt>ChatConversationPanel</tt> class and
     * its instances for logging output.
     */
    private static final Logger logger
        = Logger.getLogger(ChatConversationPanel.class);

    /**
     * The regular expression (in the form of compiled <tt>Pattern</tt>) which
     * matches URLs for the purposed of turning them into links.
     *
     * TODO Current pattern misses tailing '/' (slash) that is sometimes
     * included in URL's. (Danny)
     *
     * TODO Current implementation misses # after ? has been encountered in URL.
     * (Danny)
     */
    private static final Pattern URL_PATTERN
        = Pattern.compile(
            "("
            + "(\\bwww\\.[^\\s<>\"]+\\.[^\\s<>\"]+/*[?#]*(\\w+[&=;?]\\w+)*\\b)" // wwwURL
            + "|"
            + "(\\bjitsi\\:[^\\s<>\"]+\\.[^\\s<>\"]*\\b)" // internalURL
            + "|"
            + "(\\b\\w+://[^\\s<>\"]+/*[?#]*(\\w+[&=;?]\\w+)*\\b)" // protocolURL
            + ")");

    /**
     * A regular expression that matches a <div> tag and its contents.
     * The opening tag is group 1, and the tag contents is group 2 when
     * a match is found.
     */
    private static final Pattern DIV_PATTERN =
            Pattern.compile("(<div[^>]*>)(.*)(</div>)", Pattern.DOTALL);

    /**
     * A regular expression for searching all pieces of plain text within a blob
     * of HTML text. <i>This expression assumes that the plain text part is
     * correctly escaped, such that there is no occurrence of the symbols &lt;
     * and &gt;.</i>
     *
     * <pre>
     * In essence this regexp pattern works as follows:
     * 1. Find all the text that isn't the start of a tag. (so all chars != '<')
     *    -> This is your actual result: textual content that is not part of a
     *    tag.
     * 2. Then, if you find a '<', find as much chars as you can until you find
     *    '>' (if it is possible at all to find a closing '>')
     *
     *    In depth explanation of 2.:
     *    The text between tags consists mainly of 2 parts:
     *
     *    A) a piece of text
     *    B) some value "between quotes"
     *
     *    So everything up to the "quote" is part of a piece of text (A). Then
     *    if we encounter a "quote" we consider the rest of the text part of the
     *    value (B) until the value section is closed with a closing "quote".
     *    (We tend to be rather greedy, so we even swallow '>' along the way
     *    looking for the closing "quote".)
     *
     *    This subpattern is allowed any number of times, until eventually the
     *    closing '>' is encountered. (Or not if the pattern is incomplete.)
     *
     * 3. And consider that 2. is optional, since it could also be that we only
     *    find plain text, which would all be captured by 1.
     * </pre>
     *
     * <p>The first group matches any piece of text outside of the &lt; and &gt;
     * brackets that define the start and end of HTML tags.</p>
     */
    static final Pattern TEXT_TO_REPLACE_PATTERN = Pattern.compile(
        "([^<]*+)(?:<(?:[^>\"]*(?:\"[^\"]*+\"?)*)*+>?)?",
        Pattern.CASE_INSENSITIVE | Pattern.DOTALL);

    /**
     * List for observing text messages.
     */
    private Set<ChatLinkClickedListener> chatLinkClickedListeners =
        new HashSet<ChatLinkClickedListener>();

    /**
     * The component rendering chat conversation panel text.
     */
    private final JTextPane chatTextPane = new MyTextPane();

    /**
     * The editor kit used by the text component.
     */
    private final ChatConversationEditorKit editorKit;

    /**
     * The document used by the text component.
     */
    HTMLDocument document;

    /**
     * The parent container.
     */
    private final ChatConversationContainer chatContainer;

    /**
     * The menu shown on right button mouse click.
     */
    private ChatRightButtonMenu rightButtonMenu;

    /**
     * The currently shown href.
     */
    private String currentHref;

    /**
     * The currently shown href, is it an img element.
     */
    private boolean isCurrentHrefImg = false;

    /**
     * The copy link item, contained in the right mouse click menu.
     */
    private final JMenuItem copyLinkItem;

    /**
     * The copy link item, contained in the right mouse click menu.
     */
    private final JMenuItem configureReplacementItem;

    /**
     * The configure replacement item separator.
     */
    private final JSeparator configureReplacementSeparator = new JSeparator();

    /**
     * The open link item, contained in the right mouse click menu.
     */
    private final JMenuItem openLinkItem;

    /**
     * The right mouse click menu separator.
     */
    private final JSeparator copyLinkSeparator = new JSeparator();

    /**
     * The timestamp of the last incoming message.
     */
    private Date lastIncomingMsgTimestamp = new Date(0);

    /**
     * The timestamp of the last message.
     */
    private Date lastMessageTimestamp = new Date(0);

    /**
     * Indicates if this component is rendering a history conversation.
     */
    private final boolean isHistory;

    /**
     * The indicator which determines whether an automatic scroll to the bottom
     * of {@link #chatTextPane} is to be performed.
     */
    private boolean scrollToBottomIsPending = false;

    private String lastMessageUID = null;

    private boolean isSimpleTheme = true;

    private ShowPreviewDialog showPreview
        = new ShowPreviewDialog(ChatConversationPanel.this);

    /**
     * The implementation of the routine which scrolls {@link #chatTextPane} to
     * its bottom.
     */
    private final Runnable scrollToBottomRunnable = new Runnable()
    {
        /*
         * Implements Runnable#run().
         */
        public void run()
        {
            JScrollBar verticalScrollBar = getVerticalScrollBar();

            if (verticalScrollBar != null)
            {
                // We need to call both methods in order to be sure to scroll
                // to the bottom of the text even when the user has selected
                // something (changed the caret) or when a new tab has been
                // added or the window has been resized.
                verticalScrollBar.setValue(verticalScrollBar.getMaximum());
                Document doc = chatTextPane.getDocument();
                if(doc != null)
                {
                    int pos = document.getLength();
                    if (pos >= 0 &&
                        pos <= chatTextPane.getDocument().getLength())
                    {
                        chatTextPane.setCaretPosition(pos);
                    }
                }
            }
        }
    };

    /**
     * Creates an instance of <tt>ChatConversationPanel</tt>.
     *
     * @param chatContainer The parent <tt>ChatConversationContainer</tt>.
     */
    public ChatConversationPanel(ChatConversationContainer chatContainer)
    {
        editorKit = new ChatConversationEditorKit(this);

        this.chatContainer = chatContainer;

        isHistory = (chatContainer instanceof HistoryWindow);

        this.rightButtonMenu = new ChatRightButtonMenu(this);

        this.document = (HTMLDocument) editorKit.createDefaultDocument();

        this.document.addDocumentListener(editorKit);

        this.chatTextPane.setEditorKitForContentType("text/html", editorKit);
        this.chatTextPane.setEditorKit(editorKit);
        this.chatTextPane.setEditable(false);
        this.chatTextPane.setDocument(document);
        this.chatTextPane.setDragEnabled(true);

        chatTextPane.putClientProperty(
            JEditorPane.HONOR_DISPLAY_PROPERTIES, Boolean.TRUE);
        Constants.loadSimpleStyle(
            document.getStyleSheet(), chatTextPane.getFont());

        this.chatTextPane.addHyperlinkListener(this);
        this.chatTextPane.addMouseListener(this);
        this.chatTextPane.setCursor(
            Cursor.getPredefinedCursor(Cursor.TEXT_CURSOR));

        this.addChatLinkClickedListener(showPreview);

        this.setWheelScrollingEnabled(true);

        this.setViewportView(chatTextPane);

        this.setBorder(null);

        this.setHorizontalScrollBarPolicy(
            JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);

        ToolTipManager.sharedInstance().registerComponent(chatTextPane);

        String copyLinkString
            = GuiActivator.getResources().getI18NString("service.gui.COPY_LINK");

        copyLinkItem
            = new JMenuItem(copyLinkString,
                new ImageIcon(ImageLoader.getImage(ImageLoader.COPY_ICON)));

        copyLinkItem.addActionListener(new ActionListener()
        {
            public void actionPerformed(ActionEvent e)
            {
                StringSelection stringSelection = new StringSelection(
                    currentHref);
                Clipboard clipboard = Toolkit.getDefaultToolkit()
                    .getSystemClipboard();
                clipboard.setContents(stringSelection,
                    ChatConversationPanel.this);
            }
        });

        String openLinkString
            = GuiActivator.getResources().getI18NString(
                "service.gui.OPEN_IN_BROWSER");

        openLinkItem =
            new JMenuItem(
                openLinkString,
                new ImageIcon(ImageLoader.getImage(ImageLoader.BROWSER_ICON)));

        openLinkItem.addActionListener(new ActionListener()
        {
            public void actionPerformed(ActionEvent e)
            {
                GuiActivator.getBrowserLauncher().openURL(currentHref);

                // after opening the link remove the currentHref to avoid
                // clicking on the window to gain focus to open the link again
                ChatConversationPanel.this.currentHref = "";
            }
        });

        openLinkItem.setMnemonic(
            GuiActivator.getResources().getI18nMnemonic(
                "service.gui.OPEN_IN_BROWSER"));

        copyLinkItem.setMnemonic(
            GuiActivator.getResources().getI18nMnemonic(
                "service.gui.COPY_LINK"));

        configureReplacementItem = new JMenuItem(
            GuiActivator.getResources().getI18NString(
                "plugin.chatconfig.replacement.CONFIGURE_REPLACEMENT"),
            GuiActivator.getResources().getImage(
                "service.gui.icons.CONFIGURE_ICON"));

        configureReplacementItem.addActionListener(new ActionListener()
        {
            public void actionPerformed(ActionEvent e)
            {
                final ConfigurationContainer configContainer
                    = GuiActivator.getUIService().getConfigurationContainer();

                ConfigurationForm chatConfigForm = getChatConfigForm();

                if (chatConfigForm != null)
                {
                    configContainer.setSelected(chatConfigForm);

                    configContainer.setVisible(true);
                }
            }
        });

        this.isSimpleTheme = ConfigurationUtils.isChatSimpleThemeEnabled();

        /*
         * When we append a new message (regardless of whether it is a string or
         * an UI component), we want to make it visible in the viewport of this
         * JScrollPane so that the user can see it.
         */
        ComponentListener componentListener = new ComponentAdapter()
        {
            @Override
            public void componentResized(ComponentEvent e)
            {
                synchronized (scrollToBottomRunnable)
                {
                    if (!scrollToBottomIsPending)
                        return;
                    scrollToBottomIsPending = false;

                    /*
                     * Yana Stamcheva, pointed out that Java 5 (on Linux only?)
                     * needs invokeLater for JScrollBar.
                     */
                    SwingUtilities.invokeLater(scrollToBottomRunnable);
                }
            }
        };

        chatTextPane.addComponentListener(componentListener);
        getViewport().addComponentListener(componentListener);
    }

    /**
     * Overrides Component#setBounds(int, int, int, int) in order to determine
     * whether an automatic scroll of #chatTextPane to its bottom will be
     * necessary at a later time in order to keep its vertical scroll bar to its
     * bottom after the realization of the resize if it is at its bottom before
     * the resize.
     */
    @Override
    public void setBounds(int x, int y, int width, int height)
    {
        synchronized (scrollToBottomRunnable)
        {
            JScrollBar verticalScrollBar = getVerticalScrollBar();

            if (verticalScrollBar != null)
            {
                BoundedRangeModel verticalScrollBarModel
                    = verticalScrollBar.getModel();

                if ((verticalScrollBarModel.getValue()
                                + verticalScrollBarModel.getExtent()
                            >= verticalScrollBarModel.getMaximum())
                        || !verticalScrollBar.isVisible())
                    scrollToBottomIsPending = true;
            }
        }

        super.setBounds(x, y, width, height);
    }

    /**
     * Retrieves the contents of the sent message with the given ID.
     *
     * @param messageUID The ID of the message to retrieve.
     * @return The contents of the message, or null if the message is not found.
     */
    public String getMessageContents(String messageUID)
    {
        Element root = document.getDefaultRootElement();
        Element e = document.getElement(
            root,
            Attribute.ID,
            ChatHtmlUtils.MESSAGE_TEXT_ID + messageUID);
        if (e == null)
        {
            logger.warn("Could not find message with ID " + messageUID);
            return null;
        }

        Object original_message = e.getAttributes().getAttribute(
                ChatHtmlUtils.ORIGINAL_MESSAGE_ATTRIBUTE);
        if (original_message == null)
        {
            logger.warn("Message with ID " + messageUID +
                    " does not have original_message attribute");
            return null;
        }

        String res = StringEscapeUtils.unescapeXml(original_message.toString());
        // Remove all newline characters that were inserted to make copying
        // newlines from the conversation panel work.
        // They shouldn't be in the write panel, because otherwise a newline
        // would consist of two chars, one of them invisible (the &#10;), but
        // both of them have to be deleted in order to remove it.
        // On the other hand this means that copying newlines from the write
        // area produces only spaces, but this seems like the better option.
        res = res.replace("&#10;", "");
        return res;
    }

    /**
     * Processes the message given by the parameters.
     *
     * @param chatMessage the message
     * @param keyword a substring of <tt>chatMessage</tt> to be highlighted upon
     * display of <tt>chatMessage</tt> in the UI
     * @return the processed message
     */
    public String processMessage(   ChatMessage chatMessage,
                                    String keyword,
                                    ProtocolProviderService protocolProvider,
                                    String contactAddress)
    {
        // If this is a consecutive message don't go through the initiation
        // and just append it.
        if (isConsecutiveMessage(chatMessage))
        {
            appendConsecutiveMessage(chatMessage, keyword);
            return null;
        }

        String contentType = chatMessage.getContentType();

        lastMessageTimestamp = chatMessage.getDate();

        String contactName = chatMessage.getContactName();
        String contactDisplayName = chatMessage.getContactDisplayName();
        if (contactDisplayName == null
                || contactDisplayName.trim().length() <= 0)
            contactDisplayName = contactName;
        else
        {
            // for some reason &apos; is not rendered correctly from our ui,
            // lets use its equivalent. Other similar chars(< > & ") seem ok.
            contactDisplayName
                = contactDisplayName.replaceAll("&apos;", "&#39;");
        }

        Date date = chatMessage.getDate();
        String messageType = chatMessage.getMessageType();
        String messageTitle = chatMessage.getMessageTitle();
        String message = chatMessage.getMessage();
        String chatString = "";
        String endHeaderTag = "";

        lastMessageUID = chatMessage.getMessageUID();

        if (messageType.equals(Chat.INCOMING_MESSAGE))
        {
            this.lastIncomingMsgTimestamp = new Date();

            chatString = ChatHtmlUtils.createIncomingMessageTag(
                lastMessageUID,
                contactName,
                contactDisplayName,
                getContactAvatar(protocolProvider, contactAddress),
                date,
                formatMessageAsHTML(message, contentType, keyword),
                ChatHtmlUtils.HTML_CONTENT_TYPE,
                false,
                isSimpleTheme);
        }
        else if (messageType.equals(Chat.OUTGOING_MESSAGE))
        {
            chatString = ChatHtmlUtils.createOutgoingMessageTag(
                lastMessageUID,
                contactName,
                contactDisplayName,
                getContactAvatar(protocolProvider),
                date,
                formatMessageAsHTML(message, contentType, keyword),
                ChatHtmlUtils.HTML_CONTENT_TYPE,
                false,
                isSimpleTheme);
        }
        else if (messageType.equals(Chat.HISTORY_INCOMING_MESSAGE))
        {
            chatString = ChatHtmlUtils.createIncomingMessageTag(
                lastMessageUID,
                contactName,
                contactDisplayName,
                getContactAvatar(protocolProvider, contactAddress),
                date,
                formatMessageAsHTML(message, contentType, keyword),
                ChatHtmlUtils.HTML_CONTENT_TYPE,
                true,
                isSimpleTheme);
        }
        else if (messageType.equals(Chat.HISTORY_OUTGOING_MESSAGE))
        {
            chatString = ChatHtmlUtils.createOutgoingMessageTag(
                lastMessageUID,
                contactName,
                contactDisplayName,
                getContactAvatar(protocolProvider),
                date,
                formatMessageAsHTML(message, contentType, keyword),
                ChatHtmlUtils.HTML_CONTENT_TYPE,
                true,
                isSimpleTheme);
        }
        else if (messageType.equals(Chat.SMS_MESSAGE))
        {
            chatString = ChatHtmlUtils.createIncomingMessageTag(
                lastMessageUID,
                contactName,
                contactDisplayName,
                getContactAvatar(protocolProvider, contactAddress),
                date,
                ConfigurationUtils.isSmsNotifyTextDisabled() ?
                    formatMessageAsHTML(message, contentType, keyword)
                    : formatMessageAsHTML("SMS: " + message, contentType, keyword),
                ChatHtmlUtils.HTML_CONTENT_TYPE,
                false,
                isSimpleTheme);
        }
        else if (messageType.equals(Chat.STATUS_MESSAGE))
        {
            chatString = "<div id=\"statusMessage\" date=\"" + date + "\""
                + " style=\"color: #8F8F8F; font-size: 8px;\">";
            endHeaderTag = "</div>";

            chatString +=
                GuiUtils.formatTime(date)
                    + " "
                    + StringEscapeUtils.escapeHtml4(contactName) + " "
                    + formatMessageAsHTML(message, contentType, keyword)
                    + endHeaderTag;
        }
        else if (messageType.equals(Chat.ACTION_MESSAGE))
        {
            chatString =    "<p id=\"actionMessage\" date=\""
                            + date + "\">";
            endHeaderTag = "</p>";

            chatString += "* " + GuiUtils.formatTime(date)
                + " " + StringEscapeUtils.escapeHtml4(contactName) + " "
                + formatMessageAsHTML(message, contentType, keyword)
                + endHeaderTag;
        }
        else if (messageType.equals(Chat.SYSTEM_MESSAGE))
        {
            String startSystemDivTag =
                "<DIV id=\"systemMessage\" style=\"color:#627EB7;\">";
            String endDivTag = "</DIV>";

            chatString +=
                startSystemDivTag
                    + formatMessageAsHTML(message, contentType, keyword)
                    + endDivTag;
        }
        else if (messageType.equals(Chat.ERROR_MESSAGE))
        {
            chatString      = "<h6 id=\""
                            + ChatHtmlUtils.MESSAGE_HEADER_ID
                            + "\" date=\""
                            + date + "\">";

            endHeaderTag = "</h6>";

            String errorIcon = "<IMG SRC=\""
                + ImageLoader.getImageUri(ImageLoader.EXCLAMATION_MARK)
                + "\"></IMG>";

            // If the message title is null do not show it and show the error
            // icon on the same line as the actual error message.
            if (messageTitle != null)
            {
                chatString +=
                    errorIcon + StringEscapeUtils.escapeHtml4(messageTitle)
                        + endHeaderTag + "<h5>"
                        + formatMessageAsHTML(message, contentType, keyword)
                        + "</h5>";
            }
            else
            {
                chatString +=
                    endHeaderTag + "<h5>" + errorIcon + " "
                        + formatMessageAsHTML(message, contentType, keyword)
                        + "</h5>";
            }
        }

        return chatString;
    }

    /**
     * Processes the message given by the parameters.
     *
     * @param chatMessage the message.
     * @return the formatted message
     */
    public String processMessage(   ChatMessage chatMessage,
                                    ProtocolProviderService protocolProvider,
                                    String contactAddress)
    {
        return processMessage(  chatMessage,
                                null,
                                protocolProvider,
                                contactAddress);
    }

    /**
     * Appends a consecutive message to the document.
     *
     * @param chatMessage the message to append
     * @param keyword the keywords to highlight
     */
    public void appendConsecutiveMessage(final ChatMessage chatMessage,
        final String keyword)
    {
        String previousMessageUID = lastMessageUID;
        lastMessageUID = chatMessage.getMessageUID();

        if (!SwingUtilities.isEventDispatchThread())
        {
            SwingUtilities.invokeLater(new Runnable()
            {
                public void run()
                {
                    appendConsecutiveMessage(chatMessage, keyword);
                }
            });
            return;
        }

        Element lastMsgElement = document.getElement(
            ChatHtmlUtils.MESSAGE_TEXT_ID + previousMessageUID);

        String contactAddress
            = (String) lastMsgElement.getAttributes()
                .getAttribute(Attribute.NAME);

        boolean isHistory
            = (chatMessage.getMessageType()
                .equals(Chat.HISTORY_INCOMING_MESSAGE)
                || chatMessage.getMessageType()
                .equals(Chat.HISTORY_OUTGOING_MESSAGE))
                ? true : false;

        String newMessage = ChatHtmlUtils.createMessageTag(
                                    chatMessage.getMessageUID(),
                                    contactAddress,
                                    formatMessageAsHTML(
                                        chatMessage.getMessage(),
                                        chatMessage.getContentType(),
                                        keyword),
                                    ChatHtmlUtils.HTML_CONTENT_TYPE,
                                    chatMessage.getDate(),
                                    false,
                                    isHistory,
                                    isSimpleTheme);

        synchronized (scrollToBottomRunnable)
        {
            try
            {
                Element parentElement = lastMsgElement.getParentElement();

                document.insertBeforeEnd(parentElement, newMessage);

                // Need to call explicitly scrollToBottom, because for some
                // reason the componentResized event isn't fired every time
                // we add text.
                SwingUtilities.invokeLater(scrollToBottomRunnable);
            }
            catch (BadLocationException ex)
            {
                logger.error("Could not replace chat message", ex);
            }
            catch (IOException ex)
            {
                logger.error("Could not replace chat message", ex);
            }
        }

        finishMessageAdd(newMessage);
    }

    /**
     * Replaces the contents of the message with ID of the corrected message
     * specified in chatMessage, with this message.
     *
     * @param chatMessage A <tt>ChatMessage</tt> that contains all the required
     * information to correct the old message.
     */
    public void correctMessage(final ChatMessage chatMessage)
    {
        if (!SwingUtilities.isEventDispatchThread())
        {
            SwingUtilities.invokeLater(new Runnable()
            {
                public void run()
                {
                    correctMessage(chatMessage);
                }
            });
            return;
        }

        String correctedUID = chatMessage.getCorrectedMessageUID();
        if (correctedUID != null && correctedUID.equals(lastMessageUID))
        {
            lastMessageUID = chatMessage.getMessageUID();
        }

        Element root = document.getDefaultRootElement();
        Element correctedMsgElement
            = document.getElement(root,
                                  Attribute.ID,
                                  ChatHtmlUtils.MESSAGE_TEXT_ID + correctedUID);

        if (correctedMsgElement == null)
        {
            logger.warn("Could not find message with ID " + correctedUID);
            return;
        }

        String contactAddress
            = (String) correctedMsgElement.getAttributes()
                .getAttribute(Attribute.NAME);

        boolean isHistory
            = (chatMessage.getMessageType()
                .equals(Chat.HISTORY_INCOMING_MESSAGE)
                || chatMessage.getMessageType()
                .equals(Chat.HISTORY_OUTGOING_MESSAGE))
                ? true : false;

        String newMessage = ChatHtmlUtils.createMessageTag(
            chatMessage.getMessageUID(),
            contactAddress,
            formatMessageAsHTML(chatMessage.getMessage(),
                            chatMessage.getContentType(),
                            ""),
            ChatHtmlUtils.HTML_CONTENT_TYPE,
            chatMessage.getDate(),
            true,
            isHistory,
            isSimpleTheme);

        synchronized (scrollToBottomRunnable)
        {
            try
            {
                document.setOuterHTML(correctedMsgElement, newMessage);

                // Need to call explicitly scrollToBottom, because for some
                // reason the componentResized event isn't fired every time
                // we add text.
                SwingUtilities.invokeLater(scrollToBottomRunnable);
            }
            catch (BadLocationException ex)
            {
                logger.error("Could not replace chat message", ex);
            }
            catch (IOException ex)
            {
                logger.error("Could not replace chat message", ex);
            }
        }

        finishMessageAdd(newMessage);
    }

    /**
     * Appends the given string at the end of the contained in this panel
     * document.
     *
     * Note: Currently, it looks like appendMessageToEnd is only called for
     * messages that are already converted to HTML. So It is quite possible that
     * we can remove the content type without any issues.
     *
     * @param original the message string to append
     * @param contentType the message's content type
     */
    public void appendMessageToEnd(final String original,
                                   final String contentType)
    {
        if (!SwingUtilities.isEventDispatchThread())
        {
            SwingUtilities.invokeLater(new Runnable()
            {
                public void run()
                {
                    appendMessageToEnd(original, contentType);
                }
            });
            return;
        }

        if (original == null)
        {
            return;
        }

        final String message;
        if (ChatHtmlUtils.HTML_CONTENT_TYPE.equalsIgnoreCase(contentType))
        {
            message = original;
        }
        else
        {
            message = StringEscapeUtils.escapeHtml4(original);
        }

        synchronized (scrollToBottomRunnable)
        {
            Element root = document.getDefaultRootElement();

            try
            {
                document.insertBeforeEnd(
                            // the body element
                            root.getElement(root.getElementCount() - 1),
                            // the message to insert
                            message);

                // Need to call explicitly scrollToBottom, because for some
                // reason the componentResized event isn't fired every time we
                // add text.
                SwingUtilities.invokeLater(scrollToBottomRunnable);
            }
            catch (BadLocationException e)
            {
                logger.error("Insert in the HTMLDocument failed.", e);
            }
            catch (IOException e)
            {
                logger.error("Insert in the HTMLDocument failed.", e);
            }
        }

        String lastElemContent = getElementContent(lastMessageUID, message);

        if (lastElemContent != null)
        {
            finishMessageAdd(lastElemContent);
        }
    }

    /**
     * Performs all operations needed in order to finish the adding of the
     * message to the document.
     *
     * @param message the message string
     * @param contentType
     */
    private void finishMessageAdd(final String message)
    {
        // If we're not in chat history case we need to be sure the document
        // has not exceeded the required size (number of messages).
        if (!isHistory)
            ensureDocumentSize();

        /*
         * Replacements will be processed only if it is enabled in the
         * property.
         */
        ConfigurationService cfg = GuiActivator.getConfigurationService();

        if (cfg.getBoolean(ReplacementProperty.REPLACEMENT_ENABLE, true)
                ||cfg.getBoolean(ReplacementProperty.REPLACEMENT_PROPOSAL, true)
                || cfg.getBoolean(
                        ReplacementProperty.getPropertyName("SMILEY"),
                        true))
        {
            processReplacement(ChatHtmlUtils.MESSAGE_TEXT_ID + lastMessageUID,
                                message);
        }
    }

    /**
    * Formats the given message. Processes the messages and replaces links to
    * video/image sources with their previews or any other substitution. Spawns
    * a separate thread for replacement.
    *
    * @param messageID the messageID element.
    * @param chatString the message.
    */
    void processReplacement(final String messageID, final String chatString)
    {
        new ReplacementWorker(messageID, chatString).start();
    }

    /**
     * Ensures that the document won't become too big. When the document reaches
     * a certain size the first message in the page is removed.
     */
    private void ensureDocumentSize()
    {
        if (document.getLength() > Chat.CHAT_BUFFER_SIZE)
        {
            String[] ids = new String[]
                                      {ChatHtmlUtils.MESSAGE_TEXT_ID,
                                       "statusMessage",
                                       "systemMessage",
                                       "actionMessage"};

            Element firstMsgElement = findElement(Attribute.ID, ids);

            int startIndex = firstMsgElement.getStartOffset();
            int endIndex = firstMsgElement.getEndOffset();

            try
            {
                // Remove the message.
                this.document.remove(startIndex, endIndex - startIndex);
            }
            catch (BadLocationException e)
            {
                logger.error("Error rem