/**************************************************************************
 OmegaT - Computer Assisted Translation (CAT) tool 
          with fuzzy matching, translation memory, keyword search, 
          glossaries, and translation leveraging into updated projects.

 Copyright (C) 2000-2006 Keith Godfrey and Maxym Mykhalchuk
               2006-2007 Henry Pijffers
               2010 Alex Buloichik, Didier Briel
               2012 Thomas Cordonnier
               2014 Piotr Kulik, Thomas Cordonnier
               2015 Yu Tang
               2018 Thomas Cordonnier
               Home page: http://www.omegat.org/
               Support center: http://groups.yahoo.com/group/OmegaT/

 This file is part of OmegaT.

 OmegaT is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by
 the Free Software Foundation, either version 3 of the License, or
 (at your option) any later version.

 OmegaT is distributed in the hope that it will be useful,
 but WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 GNU General Public License for more details.

 You should have received a copy of the GNU General Public License
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 **************************************************************************/

package org.omegat.gui.search;

import java.awt.Color;
import java.awt.Rectangle;
import java.awt.Toolkit;
import java.awt.event.ActionEvent;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.swing.AbstractAction;
import javax.swing.ActionMap;
import javax.swing.InputMap;
import javax.swing.JTextPane;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import javax.swing.event.CaretEvent;
import javax.swing.event.CaretListener;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.DefaultStyledDocument;
import javax.swing.text.MutableAttributeSet;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;

import org.omegat.core.Core;
import org.omegat.core.data.ProjectOptions;
import org.omegat.core.data.SourceTextEntry;
import org.omegat.core.search.ProjectSearcher;
import org.omegat.core.search.ReplaceMatch;
import org.omegat.core.search.SearchMatch;
import org.omegat.core.search.SearchResultEntry;
import org.omegat.core.search.Searcher;
import org.omegat.gui.editor.IEditor;
import org.omegat.gui.editor.IEditor.CaretPosition;
import org.omegat.gui.editor.IEditorFilter;
import org.omegat.gui.shortcuts.PropertiesShortcuts;
import org.omegat.util.Log;
import org.omegat.util.OStrings;
import org.omegat.util.Preferences;
import org.omegat.util.StringUtil;
import org.omegat.util.gui.StaticUIUtils;
import org.omegat.util.gui.Styles;
import org.omegat.util.gui.UIThreadsUtil;

/**
 * EntryListPane displays translation segments and, upon doubleclick of a
 * segment, instructs the main UI to jump to that segment this replaces the
 * previous huperlink interface and is much more flexible in the fonts it
 * displays than the HTML text
 * 
 * @author Keith Godfrey
 * @author Henry Pijffers (henry.pijffers@saxnot.com)
 * @author Alex Buloichik (alex73mail@gmail.com)
 * @author Didier Briel
 * @author Thomas Cordonnier
 */
@SuppressWarnings("serial")
class EntryListPane extends JTextPane {
    protected static final AttributeSet FOUND_MARK = Styles.createAttributeSet(Styles.EditorColor.COLOR_SEARCH_RESULT.getColor(), null, false, null);
    protected static final AttributeSet FOUND_MARK_BOLD = Styles.createAttributeSet(Styles.EditorColor.COLOR_SEARCH_RESULT.getColor(), null, true, null);
    protected static final AttributeSet REPLACE_MARK = Styles.createAttributeSet(Styles.EditorColor.COLOR_SEARCH_REPLACE.getColor(), null, false, null);
    protected static final AttributeSet REPLACE_MARK_BOLD = Styles.createAttributeSet(Styles.EditorColor.COLOR_SEARCH_REPLACE.getColor(), null, true, null);
    protected static final int MARKS_PER_REQUEST = 100;
    protected static final String ENTRY_SEPARATOR = "---------\n";
    private static final String KEY_GO_TO_NEXT_SEGMENT = "gotoNextSegmentMenuItem";
    private static final String KEY_GO_TO_PREVIOUS_SEGMENT = "gotoPreviousSegmentMenuItem";
    private static final String KEY_TRANSFER_FOCUS = "transferFocus";
    private static final String KEY_TRANSFER_FOCUS_BACKWARD = "transferFocusBackward";
    private static final int ENTRY_LIST_INDEX_NO_ENTRIES  = -1;
    private static final int ENTRY_LIST_INDEX_END_OF_TEXT = -2;

    private static void bindKeyStrokesFromMainMenuShortcuts(InputMap map) {
        // Add KeyStrokes Ctrl+N/P (Cmd+N/P for MacOS) to the map
        PropertiesShortcuts.MainMenuShortcuts.bindKeyStrokes(map,
                KEY_GO_TO_NEXT_SEGMENT, KEY_GO_TO_PREVIOUS_SEGMENT);
    }

    private static InputMap createDefaultInputMap(InputMap parent) {
        InputMap map = new InputMap();
        map.setParent(parent);
        bindKeyStrokesFromMainMenuShortcuts(map);

        // Add KeyStrokes: Enter, Ctrl+Enter (Cmd+Enter for MacOS)
        int CTRL_CMD_MASK = Toolkit.getDefaultToolkit().getMenuShortcutKeyMask();
        map.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
                KEY_GO_TO_NEXT_SEGMENT);
        map.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, CTRL_CMD_MASK),
                KEY_GO_TO_PREVIOUS_SEGMENT);
        return map;
    }

    private static InputMap createDefaultInputMapUseTab(InputMap parent) {
        InputMap map = new InputMap();
        map.setParent(parent);
        bindKeyStrokesFromMainMenuShortcuts(map);

        // Add KeyStrokes: TAB, Shift+TAB, Ctrl+TAB (Cmd+TAB for MacOS), Ctrl+Shift+TAB (Cmd+Shift+TAB for MacOS)
        int CTRL_CMD_MASK = Toolkit.getDefaultToolkit().getMenuShortcutKeyMask();
        map.put(KeyStroke.getKeyStroke(KeyEvent.VK_TAB, 0),
                KEY_GO_TO_NEXT_SEGMENT);
        map.put(KeyStroke.getKeyStroke(KeyEvent.VK_TAB, InputEvent.SHIFT_DOWN_MASK),
                KEY_GO_TO_PREVIOUS_SEGMENT);
        map.put(KeyStroke.getKeyStroke(KeyEvent.VK_TAB, CTRL_CMD_MASK),
                KEY_TRANSFER_FOCUS);
        map.put(KeyStroke.getKeyStroke(KeyEvent.VK_TAB, CTRL_CMD_MASK | InputEvent.SHIFT_DOWN_MASK),
                KEY_TRANSFER_FOCUS_BACKWARD);
        return map;
    }

    public EntryListPane(SearchWindow parent) {
        setDocument(new DefaultStyledDocument());
        
        setDragEnabled(true);
        if (Core.getMainWindow() != null) setFont(Core.getMainWindow().getApplicationFont());
        StaticUIUtils.makeCaretAlwaysVisible(this);
        StaticUIUtils.setCaretUpdateEnabled(this, false);
        this.m_parent = parent;

        addMouseListener(new MouseAdapter() {
            @Override
            public void mouseClicked(MouseEvent e) {
                super.mouseClicked(e);
                if (!autoSyncWithEditor && e.getClickCount() == 2 && !m_entryList.isEmpty()) {
                    getActiveDisplayedEntry().gotoEntryInEditor();
                }
            }
        });

        addFocusListener(new FocusListener() {
            @Override
            public void focusGained(FocusEvent e) {
                boolean useTabForAdvance = Core.getEditor().getSettings().isUseTabForAdvance();
                if (EntryListPane.this.useTabForAdvance != useTabForAdvance) {
                    EntryListPane.this.useTabForAdvance = useTabForAdvance;
                    initInputMap(useTabForAdvance);
                }
            }

            @Override
            public void focusLost(FocusEvent e) {
                // do nothing
            }
        });

        addCaretListener(new CaretListener() {
            @Override
            public void caretUpdate(CaretEvent e) {
                SwingUtilities.invokeLater(highlighter);

                if (autoSyncWithEditor) {
                    getActiveDisplayedEntry().gotoEntryInEditor();
                }
            }
        });

        initActions();
        useTabForAdvance = Core.getEditor() != null ? Core.getEditor().getSettings().isUseTabForAdvance() : false;
        autoSyncWithEditor = Preferences.isPreferenceDefault(Preferences.SEARCHWINDOW_AUTO_SYNC, false);
        initInputMap(useTabForAdvance);
        setEditable(false);
    }

    private void initInputMap(boolean useTabForAdvance) {
        setFocusTraversalKeysEnabled(!useTabForAdvance);
        InputMap parent = getInputMap().getParent();
        InputMap newMap = useTabForAdvance ? createDefaultInputMapUseTab(parent)
                                           : createDefaultInputMap(parent);
        setInputMap(WHEN_FOCUSED, newMap);
    }

    void setAutoSyncWithEditor(boolean autoSyncWithEditor) {
        this.autoSyncWithEditor = autoSyncWithEditor;
        if (autoSyncWithEditor) {
            getActiveDisplayedEntry().gotoEntryInEditor();
        }
    }

    /**
     * Show search result for user
     */
    public void displaySearchResult(ProjectOptions config, Collection<SearchResultEntry> entries, int numberOfResults, String templateString, boolean boldWords) {
        UIThreadsUtil.mustBeSwingThread();

        this.numberOfResults = numberOfResults;

        currentlyDisplayedMatches = null;
        m_entryList.clear();
        m_offsetList.clear();
        m_firstMatchList.clear();

        if (entries == null) {
            // empty marks - just reset
            setText("");
            return;
        }

        currentlyDisplayedMatches = new DisplayMatches(config, entries, templateString, boldWords);

        highlighter.reset();
        SwingUtilities.invokeLater(highlighter);

        if (autoSyncWithEditor) {
            getActiveDisplayedEntry().gotoEntryInEditor();
        }
    }

    private int getActiveEntryListIndex() {
        int nrEntries = getNrEntries();

        if (nrEntries == 0) {
            // No entry
            return ENTRY_LIST_INDEX_NO_ENTRIES;
        }

        if (nrEntries > 0) {
            int pos = getSelectionStart();
            for (int i = 0; i < nrEntries; i++) {
                if (pos < m_offsetList.get(i)) {
                    return i;
                }
            }
        }

        return ENTRY_LIST_INDEX_END_OF_TEXT;
    }

    protected class DisplayMatches implements Runnable {
        protected final DefaultStyledDocument doc;

        private final List<SearchMatch> matches = new ArrayList<SearchMatch>();
        private final List<SearchMatch> replMatches = new ArrayList<SearchMatch>();
        private final SearchVarExpansion template;
        private final boolean boldWords;
        private final String text;

        public DisplayMatches(final ProjectOptions config, final Collection<SearchResultEntry> entries, final String templateString, boolean boldWords) {
            UIThreadsUtil.mustBeSwingThread();

            this.doc = new DefaultStyledDocument();
            this.template = new SearchVarExpansion(config, templateString);
			try { this.template.replaceDialog = (ReplaceDialog) m_parent; } catch (Exception e) { }
            this.boldWords = boldWords;

            StringBuilder m_stringBuf = new StringBuilder();
            // display what's been found so far
            if (entries.isEmpty()) {
                // no match
                addMessage(m_stringBuf, OStrings.getString("ST_NOTHING_FOUND"));
            }

            if (entries.size() >= numberOfResults)
                addMessage(m_stringBuf, StringUtil.format(OStrings.getString("SW_MAX_FINDS_REACHED"), numberOfResults) + "\n\n");

            for (SearchResultEntry entry : entries) {
                // addEntry(m_stringBuf, entry);
                int length = m_stringBuf.length(); // length before apply
                m_stringBuf.append (template.apply (entry));
                m_offsetList.add(m_stringBuf.length());
                if (entry.getEntryNum() >= 0) m_entryList.add(entry.getEntryNum());
                // Shift all matches before display
                if (entry.getSrcMatch() != null) for (SearchMatch match: entry.getSrcMatch()) { match.move(length); matches.add(match); }
                if (entry.getTargetMatch() != null) for (SearchMatch match: entry.getTargetMatch()) { match.move(length); matches.add(match); }
                if (template.replMatches != null) for (SearchMatch match: template.replMatches) { match.move(length); this.replMatches.add(match); }
            }

            text = m_stringBuf.toString();
            try {
                doc.remove(0, doc.getLength());
                doc.insertString(0, text, null);
            } catch (Exception ex) {
                Log.log(ex);
            }
            setDocument(doc); setCaretPosition(0);

            if (Core.getMainWindow() !=null) setFont(Core.getMainWindow().getApplicationFont());
            m_parent.searchEnded(entries.size(), matches.size());

            if (!(matches.isEmpty() && replMatches.isEmpty())) {
                SwingUtilities.invokeLater(this);
            }
        }

        @Override
        public void run() {
            UIThreadsUtil.mustBeSwingThread();

            if (currentlyDisplayedMatches != this) {
                // results changed - shouldn't mark old results
                return;
            }

            int max = MARKS_PER_REQUEST, last = -1;
            AttributeSet NORM_MARK = FOUND_MARK, BOLD_MARK = FOUND_MARK_BOLD;
            for (SearchMatch m : matches) {
                if (m.getStart() <= last) {
                    if (NORM_MARK == FOUND_MARK) NORM_MARK = REPLACE_MARK; else NORM_MARK = FOUND_MARK;
                    if (BOLD_MARK == FOUND_MARK_BOLD) BOLD_MARK = REPLACE_MARK_BOLD; else BOLD_MARK = FOUND_MARK_BOLD;
                } else {
                    NORM_MARK = FOUND_MARK; BOLD_MARK = FOUND_MARK_BOLD;
                }
                if (boldWords) {
                    SearchMatch word = m.englobeWord(text);
                    doc.setCharacterAttributes(word.getStart(), word.getLength(), NORM_MARK, true);
                }
                doc.setCharacterAttributes(m.getStart(), m.getLength(), BOLD_MARK, true);
                last = m.getStart() + m.getLength();
                if (max-- <= 0) break;
            }
            max = MARKS_PER_REQUEST;
            NORM_MARK = REPLACE_MARK; BOLD_MARK = REPLACE_MARK_BOLD;
            for (SearchMatch m : replMatches) {
                if (m.getStart() <= last) {
                    if (NORM_MARK == FOUND_MARK) NORM_MARK = REPLACE_MARK; else NORM_MARK = FOUND_MARK;
                    if (BOLD_MARK == FOUND_MARK_BOLD) BOLD_MARK = REPLACE_MARK_BOLD; else BOLD_MARK = FOUND_MARK_BOLD;
                } else {
                    NORM_MARK = REPLACE_MARK; BOLD_MARK = REPLACE_MARK_BOLD;
                }
                if (boldWords) {
                    SearchMatch word = m.englobeWord(text);
                    doc.setCharacterAttributes(word.getStart(), word.getLength(), NORM_MARK, true);
                }
                doc.setCharacterAttributes(m.getStart(), m.getLength(), BOLD_MARK, true);
                last = m.getStart() + m.getLength();
                if (max-- <= 0) break;
            }
        }
    }

    /**
     * Adds a message text to be displayed. Used for displaying messages that
     * aren't results.
     * 
     * @param message
     *            The message to display
     */
    private void addMessage(StringBuilder m_stringBuf, String message) {
        // Insert entry/message separator if necessary
        if (m_stringBuf.length() > 0)
            m_stringBuf.append(ENTRY_SEPARATOR);

        // Insert the message text
        m_stringBuf.append(message);
    }

    public void reset() {
        displaySearchResult(null, null, 0, "", false);
    }

    public int getNrEntries() {
        return m_offsetList.size(); // m_entryList.size() is not correct because only entries with positive values are present
    }
	
	public int getNrMatches() {
		return currentlyDisplayedMatches.matches.size();
	}

    public List<Integer> getEntryList() {
        return m_entryList;
    }
    
    public List<Integer> getOffsetList() {
        return m_offsetList;
    }

    public Searcher getSearcher() {
        return m_searcher;
    }

    private void initActions() {
        ActionMap actionMap = getActionMap();

        // go to next segment
        actionMap.put(KEY_GO_TO_NEXT_SEGMENT, new AbstractAction() {
            @Override
            public void actionPerformed(ActionEvent arg0) {
                getActiveDisplayedEntry().getNext().activate();
            }
        });

        // go to previous segment
        actionMap.put(KEY_GO_TO_PREVIOUS_SEGMENT, new AbstractAction() {
            @Override
            public void actionPerformed(ActionEvent arg0) {
                getActiveDisplayedEntry().getPrevious().activate();
            }
        });

        // transfer focus to next component
        actionMap.put(KEY_TRANSFER_FOCUS, new AbstractAction() {
            @Override
            public void actionPerformed(ActionEvent arg0) {
                transferFocus();
            }
        });

        // transfer focus to previous component
        actionMap.put(KEY_TRANSFER_FOCUS_BACKWARD, new AbstractAction() {
            @Override
            public void actionPerformed(ActionEvent arg0) {
                transferFocusBackward();
            }
        });
    }

    private DisplayedEntry getActiveDisplayedEntry() {
        int activeEntryListIndex = getActiveEntryListIndex();

        switch (activeEntryListIndex) {
            case ENTRY_LIST_INDEX_NO_ENTRIES:
                return new EmptyDisplayedEntry();
            case ENTRY_LIST_INDEX_END_OF_TEXT:
                // end of text (out of entries range)
                return new DisplayedEntryImpl(getNrEntries());
            default:
                return new DisplayedEntryImpl(activeEntryListIndex);
        }
    }

    private interface DisplayedEntry {

        DisplayedEntry getNext();

        DisplayedEntry getPrevious();

        void activate();
        
        void gotoEntryInEditor();
    }

    private static class EmptyDisplayedEntry implements DisplayedEntry {

        @Override
        public DisplayedEntry getNext() {
            return this;
        }

        @Override
        public DisplayedEntry getPrevious() {
            return this;
        }

        @Override
        public void activate() {
            // Do nothing
        }

        @Override
        public void gotoEntryInEditor() {
            // Do nothing
        }

    }

    private class DisplayedEntryImpl implements DisplayedEntry {

        private final int index;

        private DisplayedEntryImpl(int index) {
            this.index = index;
        }

        @Override
        public DisplayedEntry getNext() {
            if (index >= (getNrEntries() - 1)) {
                return this;
            } else {
                return new DisplayedEntryImpl(index + 1);
            }
        }

        @Override
        public DisplayedEntry getPrevious() {
            if (index == 0) {
                return this;
            } else {
                return new DisplayedEntryImpl(index - 1);
            }
        }

        @Override
        public void activate() {
            if (index >= getNrEntries()) {
                // end of text (out of entries range)
                return;
            }

            int beginPos = 0;
            if (index != 0) {
                beginPos = m_offsetList.get(index - 1) + ENTRY_SEPARATOR.length();
                int endPos = m_offsetList.get(index);
                try {
                    Rectangle endRect = modelToView(endPos);
                    scrollRectToVisible(endRect);
                } catch (BadLocationException ex) {
                    // Eat exception silently
                }
            }
            setSelectionStart(beginPos);
            setSelectionEnd(beginPos);
        }

        @Override
        public void gotoEntryInEditor() {
            if (index >= getNrEntries()) {
                // end of text (out of entries range)
                return;
            }

            final int entry = m_entryList.get(index);
            if (entry > 0) {
                final IEditor editor = Core.getEditor();
                int currEntryInEditor = editor.getCurrentEntryNumber();
                if (currEntryInEditor != 0 && entry != currEntryInEditor) {
                    final boolean isSegDisplayed = isSegmentDisplayed(entry);
                    SwingUtilities.invokeLater(new Runnable() {
                        @Override
                        public void run() {
                            if (isSegDisplayed && m_firstMatchList.containsKey(entry)) {
                                // Select search word in Editor pane
                                CaretPosition pos = m_firstMatchList.get(entry);
                                editor.gotoEntry(entry, pos);
                            } else {
                                editor.gotoEntry(entry);
                            }
                        }
                    });
                }
            }
        }

        private boolean isSegmentDisplayed(int entry) {
            IEditorFilter filter = Core.getEditor().getFilter();
            if (filter == null) {
                return true;
            } else {
                SourceTextEntry ste = Core.getProject().getAllEntries().get(entry - 1);
                return filter.allowed(ste);
            }
        }
    }

    private class SegmentHighlighter implements Runnable {

        private final MutableAttributeSet attrNormal;
        private final MutableAttributeSet attrActive;
        
        private int entryListIndex = -1;
        private int offset         = -1;
        private int length         = -1;

        public SegmentHighlighter() {
            attrNormal = new SimpleAttributeSet();
            StyleConstants.setBackground(attrNormal, getBackground());

            attrActive = new SimpleAttributeSet();
            // This is the same as the default value for
            // Styles.EditorColor.COLOR_ACTIVE_SOURCE, but we hard-code it here
            // because this panel does not currently support customized colors.
            StyleConstants.setBackground(attrActive, Color.decode("#c0ffc0"));
        }

        @Override
        public void run() {
            int activeEntryListIndex = getActiveEntryListIndex();
            if (activeEntryListIndex == ENTRY_LIST_INDEX_END_OF_TEXT) {
                // end of text (out of entries range) should belongs to the last segment
                activeEntryListIndex = getNrEntries() - 1;
            }

            if (activeEntryListIndex != entryListIndex) {
                removeCurrentHighlight();
                addHighlight(activeEntryListIndex);
            }
        }

        public void reset() {
            entryListIndex = -1;
            offset         = -1;
            length         = -1;
        }
        
        private void removeCurrentHighlight() {
            if (entryListIndex == -1 || entryListIndex >= m_offsetList.size() || length <= 0) {
                return;
            }

            getStyledDocument().setCharacterAttributes(offset, length, attrNormal, false);
            reset();
        }

        private void addHighlight(int entryListIndex) {
            if (entryListIndex == -1 || entryListIndex >= m_offsetList.size()) {
                return;
            }

            int offset = entryListIndex == 0
                    ? 0
                    : m_offsetList.get(entryListIndex - 1) + ENTRY_SEPARATOR.length();
            int length = m_offsetList.get(entryListIndex) - offset - 1; // except tail line break

            getStyledDocument().setCharacterAttributes(offset, length, attrActive, false);

            this.entryListIndex = entryListIndex;
            this.offset = offset;
            this.length = length;
        }
    }

    private volatile Searcher m_searcher;
    private final List<Integer> m_entryList = new ArrayList<Integer>();
    private final List<Integer> m_offsetList = new ArrayList<Integer>();
    private final Map<Integer, CaretPosition> m_firstMatchList = new HashMap<Integer, CaretPosition>();
    private DisplayMatches currentlyDisplayedMatches;
    private int numberOfResults;
    private SearchWindow m_parent;
    private boolean useTabForAdvance;
    private boolean autoSyncWithEditor;
    private final SegmentHighlighter highlighter = new SegmentHighlighter();
}
