/*
 * Copyright (C) 2004-2011 Jive Software. All rights reserved.
 *
 * 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 org.jivesoftware.spark.ui.transctipt;

import org.jivesoftware.Spark;
import org.jivesoftware.resource.Default;
import org.jivesoftware.resource.Res;
import org.jivesoftware.resource.SparkRes;
import org.jivesoftware.smack.packet.Message;
import org.jivesoftware.smackx.delay.packet.DelayInformation;
import org.jivesoftware.smackx.eme.element.ExplicitMessageEncryptionElement;
import org.jivesoftware.smackx.jiveproperties.packet.JivePropertiesExtension;
import org.jivesoftware.smackx.message_markup.element.MarkupElement;
import org.jivesoftware.smackx.xhtmlim.XHTMLManager;
import org.jivesoftware.spark.ChatManager;
import org.jivesoftware.spark.SparkManager;
import org.jivesoftware.spark.plugin.ContextMenuListener;
import org.jivesoftware.spark.ui.ChatArea;
import org.jivesoftware.spark.ui.ChatRoom;
import org.jivesoftware.spark.ui.history.HistoryWindow;
import org.jivesoftware.spark.util.TaskEngine;
import org.jivesoftware.spark.util.log.Log;
import org.jivesoftware.sparkimpl.plugin.manager.Enterprise;
import org.jivesoftware.sparkimpl.settings.local.LocalPreferences;
import org.jivesoftware.sparkimpl.settings.local.SettingsManager;
import org.jxmpp.jid.EntityJid;
import org.jxmpp.jid.parts.Resourcepart;

import javax.swing.*;
import javax.swing.text.*;
import java.awt.*;
import java.awt.datatransfer.StringSelection;
import java.awt.event.ActionEvent;
import java.awt.event.MouseEvent;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.List;

import static org.jivesoftware.spark.ui.transctipt.MarkupConverter.applySpans;
import static org.jivesoftware.spark.ui.transctipt.MarkupConverter.rawHtmlToMarkup;

/**
 * Provides a window in which a chat is presented to the end user, by listing all chat messages in order. This
 * implementation is usable for both one-on-one chats, as well as multi-user chats.
 *
 * The class primary responsibility is to maintain an (time-based) ordered list of entries, and provides various methods
 * to add new entries. Responsibility for the visual representation of each entry is delegated to the implementation of
 * that entry.
 *
 * @author Guus der Kinderen, guus.der.kinderen@gmail.com
 */
public class TranscriptWindow extends ChatArea implements ContextMenuListener
{
    private static final SimpleDateFormat MSG_DATE_FORMATTER = new SimpleDateFormat("hh:mm:ss");

    /**
     * Unless specifically documented otherwise, content is stored in an in-memory cache of {@link TranscriptWindowEntry}s.
     * This cache is used to recompose the UI when needed (which typically occurs when entries are added out-of-order).
     */
    private final LinkedList<TranscriptWindowEntry> entries = new LinkedList<>();
    private final Color messageColor = (Color) UIManager.get("Message.foreground");
    private final Color historyColor = (Color) UIManager.get("History.foreground");

    public TranscriptWindow()
    {
        setEditable( false );
        addMouseListener( this );
        addMouseMotionListener( this );
        setDragEnabled( true );
        addContextMenuListener( this );

        // Make sure ctrl-c works
        getInputMap( JComponent.WHEN_IN_FOCUSED_WINDOW ).put( KeyStroke.getKeyStroke( "ctrl c" ), "copy" );

        getActionMap().put( "copy", new AbstractAction( "copy" )
        {
            @Override
			public void actionPerformed( ActionEvent evt )
            {
                StringSelection stringSelection = new StringSelection( getSelectedText() );
                Toolkit.getDefaultToolkit().getSystemClipboard().setContents( stringSelection, null );
            }
        } );

    }

    // Guarded by 'this'
    boolean isReordingScheduled = false;

    private synchronized void reOrder() {
        // Clear and refill the UI component.
        try
        {
            entries.sort(Comparator.comparing(TranscriptWindowEntry::getTimestamp));
            clear();
            for ( TranscriptWindowEntry e : entries )
            {
                e.addTo( this );
            }
        }
        catch ( BadLocationException ex )
        {
            Log.error( "An exception prevented chat content to be redrawn in the user interface!", ex );
        }
        finally
        {
            isReordingScheduled = false;
        }
    }

    protected synchronized void add( TranscriptWindowEntry entry )
    {
        if ( !entries.isEmpty() )
        {
            if ( entry.getTimestamp().isBefore( entries.getLast().getTimestamp() ) && !(entries.getLast() instanceof CustomTextEntry) )
            {
                Log.debug( "A chat entry appears to have been delivered out of order. The transcript window must be reordered!" );
                if (!isReordingScheduled) {
                    Log.debug( "Scheduling new re-ordering of entries in the transcript window." );
                    isReordingScheduled = true;
                    TaskEngine.getInstance().schedule(new TimerTask()
                    {
                        @Override

                        public void run()
                        {
                            SwingUtilities.invokeLater(() -> reOrder());
                        }
                    }, 250);
                }
            }
            if (!entry.getTimestamp().toLocalDate().isEqual(entries.getLast().getTimestamp().toLocalDate())) {
                // The date appeared to have rolled over, since the last entry. Add a 'start-of-day' entry before we add the new entry.
                final StartOfDayEntry startOfDayEntry = new StartOfDayEntry( entry.getTimestamp() );
                try {
                    startOfDayEntry.addTo( this );
                } catch (BadLocationException ignored) {
                }
            }
        }
        entries.add( entry );
        try
        {
            entry.addTo( this );
        }
        catch ( BadLocationException ex )
        {
            Log.error( "An exception prevented chat content to be displayed in the user interface!", ex );
        }
    }

    /**
     * Inserts a component into the transcript window.
     *
     * @param component the component to insert.
     */
    public void addComponent( Component component )
    {
        final StyledDocument doc = (StyledDocument) getDocument();
        // The image must first be wrapped in a style
        Style style = doc.addStyle( "StyleName", null );
        StyleConstants.setComponent( style, component );
        // Insert the image at the end of the text
        try
        {
            doc.insertString( doc.getLength(), "ignored text", style );
            doc.insertString( doc.getLength(), "\n", null );
        }
        catch ( BadLocationException e )
        {
            Log.error( e );
        }
    }

    /**
     * Adds a text message this transcript window.
     *
     * Unless the provided message defines a timestamp (for instance, when a 'delay' extension is present), the message
     * timestamp is assumed to be 'now'.
     *
     * @param nickname   the nickname of the author of the message.
     * @param message    the message to insert.
     * @param foreground the color to use for the nickname (excluding the message text) foreground.
     */
    public void insertMessage( CharSequence nickname, Message message, Color foreground )
    {
        insertMessage( nickname, message, foreground, null );
    }

    /**
     * Adds a text message this transcript window.
     *
     * @param nickname   the nickname of the author of the message.
     * @param message    the message to insert.
     * @param foreground the color to use for the nickname (excluding the message text) foreground.
     * @param background the color to use for the entire background (eg, to highlight).
     */
    public void insertMessage( CharSequence nickname, Message message, Color foreground, Color background )
    {
        String nicknameStr = nickname.toString();
        for ( TranscriptWindowInterceptor interceptor : SparkManager.getChatManager().getTranscriptWindowInterceptors() )
        {
            try
            {
                boolean handled = interceptor.isMessageIntercepted( this, nicknameStr, message );
                if ( handled )
                {
                    // Do nothing.
                    return;
                }
            }
            catch ( Exception e )
            {
                Log.error( "A TranscriptWindowInterceptor ('" + interceptor + "') threw an exception while processing a chat message (current user: '" + nickname + "').", e );
            }
        }

        // Verify the timestamp of this message. Determine if it is a 'live' message, or one that was sent earlier.
        final DelayInformation inf = message.getExtension(DelayInformation.class);
        boolean isDelayed = inf != null;
        ZonedDateTime sentDate = isDelayed ? inf.getStamp().toInstant().atZone(ZoneId.systemDefault()) : ZonedDateTime.now();

        String body;
        ExplicitMessageEncryptionElement eme = message.getExtension(ExplicitMessageEncryptionElement.class);
        if (eme != null) {
            body = Res.getString("message.is_encrypted", eme.getEncryptionNamespace());
        } else {
            body = getMessageBodyByLang(message);
            if (body == null) {
                return;
            }
        }
        add( new MessageEntry( sentDate, isDelayed, nicknameStr, foreground, body, messageColor, background ) );
    }

    private static String getMessageBodyByLang(Message message) {
        String preferredLocale = Locale.getDefault().getLanguage();
        // First try to get XHTML-IM body sent by old clients (Pidgin, Psi) and convert to known XEP-0393 Message Styling
        List<CharSequence> bodies = XHTMLManager.getBodies(message);
        if (bodies != null && !bodies.isEmpty()) {
            String msgBodyRawHtml = unwrapBody(bodies.get(0).toString());
            if (msgBodyRawHtml != null) {
                return rawHtmlToMarkup(msgBodyRawHtml);
            }
        }
        String body = message.getBody(preferredLocale);
        if (body == null) {
            // return default body (no language)
            body = message.getBody();
        }
        var markup = message.getExtension(MarkupElement.class);
        if (markup != null) {
            return applySpans(body, markup.getChildElements());
        }
        return body;
    }

    /**
     * Unwrap "<body>TEXT</body>" to "TEXT"
     */
    private static String unwrapBody(String rawHtmlBody) {
        int bodyOpenTagEndPos = rawHtmlBody.indexOf('>'); // end of "<body xmlns='http://www.w3.org/1999/xhtml'>"
        if (bodyOpenTagEndPos == -1) {
            return null;
        }
        int bodyCloseTagStartPos = rawHtmlBody.lastIndexOf("</body>");
        if (bodyCloseTagStartPos == -1) {
            return null;
        }
        return rawHtmlBody.substring(bodyOpenTagEndPos + 1, bodyCloseTagStartPos);
    }


    /**
     * Adds a notification message this transcript window. A notification message generally is a presence update, but
     * can be used for most anything related to the room.
     *
     * The message timestamp is assumed to be 'now'.
     *
     * @param message         the information message to insert.
     * @param foregroundColor the foreground color to use.
     */
    public synchronized void insertNotificationMessage( String message, Color foregroundColor )
    {
        add( new CustomTextEntry( ZonedDateTime.now(), message, foregroundColor ) );
    }

    /**
     * Adds a custom text message this transcript window.
     *
     * The message timestamp is assumed to be 'now'.
     *
     * @param text       the text to insert.
     * @param bold       true to use bold text.
     * @param underline  true to have text underlined.
     * @param foreground the foreground color.
     */
    public synchronized void insertCustomText( String text, boolean bold, boolean underline, Color foreground )
    {
        add( new CustomTextEntry( ZonedDateTime.now(), text, foreground, bold, false, underline, false ) );
    }

    /**
     * Adds a custom text message this transcript window.
     *
     * The message timestamp is assumed to be 'now'.
     *
     * @param text          the text to insert.
     * @param bold          true to use bold text.
     * @param italic        true to use italic text.
     * @param underline     true to have text underlined.
     * @param strikeThrough true to have text strike through.
     * @param foreground    the foreground color.
     */
    public synchronized void insertCustomText( String text, boolean bold, boolean italic, boolean underline, boolean strikeThrough, Color foreground )
    {
        add( new CustomTextEntry( ZonedDateTime.now(), text, foreground, bold, italic, underline, strikeThrough ) );
    }

    /**
     * Return the timestamp of the last entry that was added to this transcript window.
     *
     * If there is no entries in this window, the null is returned.
     *
     * @return the timestamp of the last entry in this window, or null when there are no entries.
     */
    public Instant getLastUpdated()
    {
        if ( entries.isEmpty() )
        {
            return null;
        }
        return entries.getLast().getTimestamp().toInstant();
    }

    /**
     * Adds a historic text message to this transcript window. These typically are messages that were added to a chat
     * before the local user joined the chat.
     *
     * @param userid  the userid of the sender.
     * @param message the message to insert.
     * @param date    the timestamp of the message.
     */
    public void insertHistoryMessage( String userid, String message, Date date )
    {
        final ZonedDateTime sentDate = date.toInstant().atZone(ZoneId.systemDefault());
        add(new MessageEntry(sentDate, true, userid, historyColor, message, historyColor, null));
    }

    public void insertHorizontalLine()
    {
        add( new HorizontalLineEntry() );
    }

    /**
     * Disable the entire <code>TranscriptWindow</code> and visually represent
     * it as disabled.
     */
    public void showWindowDisabled()
    {
        final Document document = getDocument();
        final SimpleAttributeSet attrs = new SimpleAttributeSet();
        StyleConstants.setForeground( attrs, Color.LIGHT_GRAY );

        final int length = document.getLength();
        StyledDocument styledDocument = getStyledDocument();
        styledDocument.setCharacterAttributes( 0, length, attrs, false );
    }

    /**
     * Persist a current transcript.
     *
     * @param fileName   the name of the file to save the transcript as. Note: This can be modified by the user.
     * @param transcript the collection of transcript.
     * @param headerData the string to prepend to the transcript.
     * @see ChatRoom#getTranscripts()
     */
    public void saveTranscript( String fileName, List<Message> transcript, String headerData )
    {
        final LocalPreferences pref = SettingsManager.getLocalPreferences();
        try
        {
            File defaultSaveFile = new File(Spark.getSparkUserHome(), fileName);
            final JFileChooser fileChooser = new JFileChooser( defaultSaveFile );
            fileChooser.setSelectedFile( defaultSaveFile );

            // Show save dialog; this method does not return until the dialog is closed
            int result = fileChooser.showSaveDialog( this );
            final File selFile = fileChooser.getSelectedFile();

            if ( selFile != null && result == JFileChooser.APPROVE_OPTION )
            {
                final StringBuilder buf = new StringBuilder();
                buf.append( "<html><body>" );
                if ( headerData != null )
                {
                    buf.append( headerData );
                }

                buf.append( "<table width=600>" );
                Resourcepart ownNickname = pref.getNickname();
                for (Message message : transcript) {
                    String from = getMessageFrom(message, ownNickname);
                    final String body = message.getBody();
                    final JivePropertiesExtension extension = message.getExtension(JivePropertiesExtension.class);
                    Date insertionDate = extension != null ? (Date) extension.getProperty("insertionDate") : null;
                    String value = insertionDate != null ? "(" + MSG_DATE_FORMATTER.format(insertionDate) + ") " : "";
                    buf.append( "<tr><td nowrap><font size=2>" ).append( value ).append( "<strong>" ).append( from ).append( ":</strong>&nbsp;" ).append( body ).append( "</font></td></tr>" );
                }
                buf.append( "</table></body></html>" );
                final BufferedWriter writer = new BufferedWriter( new FileWriter( selFile ) );
                writer.write( buf.toString() );
                writer.close();
                JOptionPane.showMessageDialog( SparkManager.getMainWindow(), "Chat transcript has been saved.",
                                               "Chat Transcript Saved", JOptionPane.INFORMATION_MESSAGE );
            }
        }
        catch ( Exception ex )
        {
            Log.error( "Unable to save chat transcript.", ex );
            JOptionPane.showMessageDialog( SparkManager.getMainWindow(), "Could not save transcript.", "Error", JOptionPane.ERROR_MESSAGE );
        }
    }

    private static String getMessageFrom(Message message, Resourcepart ownNickname) {
        if (message.getFrom() != null) {
            if (Message.Type.groupchat == message.getType()) {
                Resourcepart fromResource = message.getFrom().getResourceOrNull();
                if (fromResource != null) {
                    return fromResource.toString();
                }
            }
            return message.getFrom().asUnescapedString();
        }
        return ownNickname.toString();
    }

    public void cleanup()
    {
        super.releaseResources();

        clear();

        removeMouseListener( this );
        removeMouseMotionListener( this );

        removeContextMenuListener( this );
        getActionMap().remove( "copy" );
    }

    /**
     * Adds Print and Clear actions.
     *
     * @param object the TransferWindow
     * @param popup  the popup menu to add to.
     */
    @Override
	public void poppingUp( final Object object, JPopupMenu popup )
    {
        popup.addSeparator();
        popup.add(new AbstractAction(Res.getString("action.print"), SparkRes.getImageIcon(SparkRes.Icon.PRINTER_IMAGE_16x16))
        {
            @Override
			public void actionPerformed( ActionEvent actionEvent )
            {
                SparkManager.printChatTranscript( (TranscriptWindow) object );
            }
        } );

        if (!Default.getBoolean(Default.HIDE_HISTORY_SETTINGS) && Enterprise.containsFeature(Enterprise.HISTORY_SETTINGS_FEATURE)
        		&& !Default.getBoolean(Default.HISTORY_DISABLED) && Enterprise.containsFeature(Enterprise.HISTORY_TRANSCRIPTS_FEATURE)) {
            popup.add(new AbstractAction(Res.getString("action.clear"), SparkRes.getImageIcon(SparkRes.Icon.ERASER_IMAGE))
            {
                @Override
				public void actionPerformed( ActionEvent actionEvent )
                {
                    String user = null;
                    try
                    {
                        ChatManager manager = SparkManager.getChatManager();
                        ChatRoom room = manager.getChatContainer().getActiveChatRoom();
                        user = room.getBareJid().toString();

                        int ok = JOptionPane.showConfirmDialog( (TranscriptWindow) object,
                                                                Res.getString( "delete.permanently" ),
                                                                Res.getString( "delete.log.permanently" ),
                                                                JOptionPane.YES_NO_OPTION,
                                                                JOptionPane.QUESTION_MESSAGE );
                        if ( ok == JOptionPane.YES_OPTION )
                        {
                            // This actions must be move into Transcript Plugin!
                            File transcriptDir = SparkManager.getTranscriptDir();
                            File transcriptFile = new File( transcriptDir, user + ".xml" );
                            //noinspection ResultOfMethodCallIgnored
                            transcriptFile.delete();
                            transcriptFile = new File( transcriptDir, user + "_current.xml" );
                            //noinspection ResultOfMethodCallIgnored
                            transcriptFile.delete();
                            clear();
                        }
                    }
                    catch ( Exception ex )
                    {
                        Log.error( "An exception occurred while trying to clear history for a chat room " + user, ex );
                    }
                }
            } );
        }
        ChatRoom room = null;
        try{
            room = SparkManager.getChatManager().getChatContainer().getActiveChatRoom();
        }catch (Exception e){
            Log.error( "An exception occurred while trying to get active chat room " + room, e );
        }

        // History window
        if (!Default.getBoolean(Default.HISTORY_DISABLED) && Enterprise.containsFeature(Enterprise.HISTORY_TRANSCRIPTS_FEATURE)) {
            EntityJid roomJid = room.getJid();
            popup.add(new AbstractAction(Res.getString("action.viewlog")) {
                @Override
                public void actionPerformed(ActionEvent e) {
                    try {
                        HistoryWindow hw = new HistoryWindow(roomJid);
                        hw.showWindow();
                    } catch (Exception ex) {
                        Log.error("An exception occurred while trying to open history window for room " + roomJid, ex);
                    }
                }
            });
        }
    }

    @Override
	public boolean handleDefaultAction( MouseEvent e )
    {
        return false;
    }
}
