/*
 * @(#)TextComponentSearchable.java 10/11/2005
 *
 * Copyright 2002 - 2005 JIDE Software Inc. All rights reserved.
 */
package com.jidesoft.swing;

import com.jidesoft.swing.event.SearchableEvent;

import javax.swing.*;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.util.HashMap;
import java.util.Iterator;

/**
 * <code>TextComponentSearchable</code> is an concrete implementation of {@link Searchable} that enables the search
 * function in JTextComponent. <p>It's very simple to use it. Assuming you have a JTextComponent, all you need to do is
 * to call
 * <code><pre>
 * JTextComponent textComponent = ....;
 * TextComponentSearchable searchable = new TextComponentSearchable(textComponent);
 * </pre></code>
 * Now the JTextComponent will have the search function.
 * <p/>
 * There is very little customization you need to do to ListSearchable. The only thing you might need is when the
 * element in the JTextComponent needs a special conversion to convert to string. If so, you can override
 * convertElementToString() to provide you own algorithm to do the conversion.
 * <code><pre>
 * JTextComponent textComponent = ....;
 * TextComponentSearchable searchable = new ListSearchable(textComponent) {
 *      protected String convertElementToString(Object object) {
 *          ...
 *      }
 * <p/>
 *      protected boolean isActivateKey(KeyEvent e) { // change to a different activation key
 *          return (e.getID() == KeyEvent.KEY_PRESSED && e.getKeyCode() == KeyEvent.VK_F &&
 * (KeyEvent.CTRL_MASK & e.getModifiers()) != 0);
 *      }
 * };
 * </pre></code>
 * <p/>
 * Additional customization can be done on the base Searchable class such as background and foreground color,
 * keystrokes, case sensitivity. TextComponentSearchable also has a special attribute called highlightColor. You can
 * change it using {@link #setHighlightColor(java.awt.Color)}.
 * <p/>
 * Due to the special case of JTextComponent, the searching doesn't support wild card '*' or '?' as in other
 * Searchables. The other difference is JTextComponent will keep the highlights after search popup hides. If you want to
 * hide the highlights, just press ESC again (the first ESC will hide popup; the second ESC will hide all highlights if
 * any).
 */
public class TextComponentSearchable extends Searchable implements DocumentListener, PropertyChangeListener {
    private Highlighter.HighlightPainter _highlightPainter;
    private static final Color DEFAULT_HIGHLIGHT_COLOR = new Color(204, 204, 255);
    private Color _highlightColor = null;
    private int _selectedIndex = -1;
    private HighlighCache _highlighCache;

    public TextComponentSearchable(JTextComponent textComponent) {
        super(textComponent);
        _highlighCache = new HighlighCache();
        installHighlightsRemover();
        setHighlightColor(DEFAULT_HIGHLIGHT_COLOR);
    }

    /**
     * Uninstalls the handler for ESC key to remove all highlights
     */
    public void uninstallHighlightsRemover() {
        DelegateAction.restoreAction(_component, JComponent.WHEN_FOCUSED, KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0));
    }

    /**
     * Installs the handler for ESC key to remove all highlights
     */
    public void installHighlightsRemover() {
        DelegateAction.replaceAction(_component, JComponent.WHEN_FOCUSED, KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), new DelegateAction() {
            private static final long serialVersionUID = 8572103995404313032L;

            @Override
            public boolean isDelegateEnabled() {
                if (_component instanceof JTextComponent) {
                    Iterator highlights = _highlighCache.getAllHighlights();
                    if (!highlights.hasNext()) {
                        return false;
                    }
                }
                return super.isDelegateEnabled();
            }

            @Override
            public boolean delegateActionPerformed(ActionEvent e) {
                if (_component instanceof JTextComponent) {
                    Iterator highlights = _highlighCache.getAllHighlights();
                    if (!highlights.hasNext()) {
                        return false;
                    }
                }
                removeAllHighlights();
                return true;
            }
        });
    }

    @Override
    public void installListeners() {
        super.installListeners();
        if (_component instanceof JTextComponent) {
            ((JTextComponent) _component).getDocument().addDocumentListener(this);
            _component.addPropertyChangeListener("document", this);
        }
    }

    @Override
    public void uninstallListeners() {
        super.uninstallListeners();
        if (_component instanceof JTextComponent) {
            ((JTextComponent) _component).getDocument().removeDocumentListener(this);
            _component.removePropertyChangeListener("document", this);
        }
    }

    @Override
    protected void setSelectedIndex(int index, boolean incremental) {
        if (_component instanceof JTextComponent) {
            if (index == -1) {
                removeAllHighlights();
                _selectedIndex = -1;
                return;
            }

            if (!incremental) {
                removeAllHighlights();
            }

            String text = getSearchingText();
            try {
                addHighlight(index, text, incremental);
            }
            catch (BadLocationException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * Adds highlight to text component at specified index and text.
     *
     * @param index       the index of the text to be highlighted
     * @param text        the text to be highlighted
     * @param incremental if this is an incremental adding highlight
     * @throws BadLocationException
     */
    protected void addHighlight(final int index, final String text, boolean incremental) throws BadLocationException {
        if (_component instanceof JTextComponent) {
            final JTextComponent textComponent = ((JTextComponent) _component);
            Object obj = textComponent.getHighlighter().addHighlight(index, index + text.length(), _highlightPainter);
            _highlighCache.addHighlight(obj);
            _selectedIndex = index;
            if (!incremental) {
                Runnable runnable = new Runnable() {
                    public void run() {
                        scrollTextVisible(textComponent, index, text.length());
                    }
                };
                SwingUtilities.invokeLater(runnable);
            }
        }
    }

    private void scrollTextVisible(JTextComponent textComponent, int index, int length) {
        // scroll highlight visible
        if (index != -1) {
            // Scroll the component if needed so that the composed text
            // becomes visible.
            try {
                Rectangle begin = textComponent.modelToView(index);
                if (begin == null) {
                    return;
                }
                Rectangle end = textComponent.modelToView(index + length);
                if (end == null) {
                    return;
                }
                Rectangle bounds = _component.getVisibleRect();
                if (begin.x <= bounds.width) { // make sure if scroll back to the beginning as long as selected rect is visible
                    begin.width = end.x;
                    begin.x = 0;
                }
                else {
                    begin.width = end.x - begin.x;
                }
                textComponent.scrollRectToVisible(begin);
            }
            catch (BadLocationException ble) {
            }
        }
    }

    /**
     * Removes all highlights from the text component.
     */
    protected void removeAllHighlights() {
        if (_component instanceof JTextComponent) {
            Iterator itor = _highlighCache.getAllHighlights();
            while (itor.hasNext()) {
                Object o = itor.next();
                ((JTextComponent) _component).getHighlighter().removeHighlight(o);
            }
            _highlighCache.removeAllHighlights();
        }
    }

    @Override
    protected int getSelectedIndex() {
        if (_component instanceof JTextComponent) {
            return _selectedIndex;
        }
        return 0;
    }

    @Override
    protected Object getElementAt(int index) {
        String text = getSearchingText();
        if (text != null) {
            if (_component instanceof JTextComponent) {
                int endIndex = index + text.length();
                int elementCount = getElementCount();
                if (endIndex > elementCount) {
                    endIndex = getElementCount();
                }
                try {
                    return ((JTextComponent) _component).getDocument().getText(index, endIndex - index + 1);
                }
                catch (BadLocationException e) {
                    return null;
                }
            }
        }
        return "";
    }

    @Override
    protected int getElementCount() {
        if (_component instanceof JTextComponent) {
            return ((JTextComponent) _component).getDocument().getLength();
        }
        return 0;
    }

    /**
     * Converts the element in JTextComponent to string. The returned value will be the <code>toString()</code> of
     * whatever element that returned from <code>list.getModel().getElementAt(i)</code>.
     *
     * @param object
     * @return the string representing the element in the JTextComponent.
     */
    @Override
    protected String convertElementToString(Object object) {
        if (object != null) {
            return object.toString();
        }
        else {
            return "";
        }
    }

    public void propertyChange(PropertyChangeEvent evt) {
        if (isProcessModelChangeEvent()) {
            hidePopup();
            _text = null;
            if (evt.getOldValue() instanceof Document) {
                ((Document) evt.getNewValue()).removeDocumentListener(this);
            }
            if (evt.getNewValue() instanceof Document) {
                ((Document) evt.getNewValue()).addDocumentListener(this);
            }
            fireSearchableEvent(new SearchableEvent(this, SearchableEvent.SEARCHABLE_MODEL_CHANGE));
        }
    }

    public void insertUpdate(DocumentEvent e) {
        if (isProcessModelChangeEvent()) {
            hidePopup();
            _text = null;
            fireSearchableEvent(new SearchableEvent(this, SearchableEvent.SEARCHABLE_MODEL_CHANGE));
        }
    }

    public void removeUpdate(DocumentEvent e) {
        if (isProcessModelChangeEvent()) {
            hidePopup();
            _text = null;
            fireSearchableEvent(new SearchableEvent(this, SearchableEvent.SEARCHABLE_MODEL_CHANGE));
        }
    }

    public void changedUpdate(DocumentEvent e) {
        if (isProcessModelChangeEvent()) {
            hidePopup();
            _text = null;
            fireSearchableEvent(new SearchableEvent(this, SearchableEvent.SEARCHABLE_MODEL_CHANGE));
        }
    }

    @Override
    protected boolean isActivateKey(KeyEvent e) {
        if (_component instanceof JTextComponent && ((JTextComponent) _component).isEditable()) {
            return (e.getID() == KeyEvent.KEY_PRESSED && e.getKeyCode() == KeyEvent.VK_F && JideSwingUtilities.isMenuShortcutKeyDown(e));
        }
        else {
            return super.isActivateKey(e);
        }
    }

    /**
     * Gets the highlight color.
     *
     * @return the highlight color.
     */
    public Color getHighlightColor() {
        if (_highlightColor != null) {
            return _highlightColor;
        }
        else {
            return DEFAULT_HIGHLIGHT_COLOR;
        }
    }

    /**
     * Changes the highlight color.
     *
     * @param highlightColor
     */
    public void setHighlightColor(Color highlightColor) {
        _highlightColor = highlightColor;
        _highlightPainter = new DefaultHighlighter.DefaultHighlightPainter(_highlightColor);
    }

    @Override
    public int findLast(String s) {
        if (_component instanceof JTextComponent) {
            String text = getDocumentText();
            if (isCaseSensitive()) {
                return text.lastIndexOf(s);
            }
            else {
                return lastIndexOf(text, s, text.length());
            }
        }
        else {
            return super.findLast(s);
        }
    }

    private String _text = null;

    /**
     * Gets the text from Document.
     *
     * @return the text of this JTextComponent. It used Document to get the text.
     */
    private String getDocumentText() {
        if (_text == null) {
            Document document = ((JTextComponent) _component).getDocument();
            try {
                String text = document.getText(0, document.getLength());
                _text = text;
            }
            catch (BadLocationException e) {
                return "";
            }
        }
        return _text;
    }

    @Override
    public int findFirst(String s) {
        if (_component instanceof JTextComponent) {
            String text = getDocumentText();
            if (isCaseSensitive()) {
                return text.indexOf(s);
            }
            else {
                return indexOf(text, s, 0);
            }
        }
        else {
            return super.findFirst(s);
        }
    }

    static int lastIndexOf(String source, String target, int fromIndex) {
        int sourceCount = source.length();
        int targetCount = target.length();
        int rightIndex = sourceCount - targetCount;
        if (fromIndex < 0) {
            return -1;
        }
        if (fromIndex > rightIndex) {
            fromIndex = rightIndex;
        }
        /* Empty string always matches. */
        if (targetCount == 0) {
            return fromIndex;
        }
        char[] lowerTarget = target.toLowerCase().toCharArray();
        char[] upperTarget = target.toUpperCase().toCharArray();

        int strLastIndex = targetCount - 1;
        int min = targetCount - 1;
        int i = min + fromIndex;

        while (i >= min) {
            while (i >= min && source.charAt(i) != lowerTarget[strLastIndex] && source.charAt(i) != upperTarget[strLastIndex]) {
                i--;
            }
            if (i < min) {
                break;
            }
            int j = i - 1;
            int start = j - (targetCount - 1);
            int k = strLastIndex - 1;

            while (j > start) {
                char ch = source.charAt(j);
                if (ch != lowerTarget[k] && ch != upperTarget[k]) {
                    i--;
                    break;
                }
                j--;
                k--;
            }
            if (j <= start) {
                return start + 1;
            }
        }
        return -1;
    }

    private static int indexOf(String source, String target, int fromIndex) {
        int sourceCount = source.length();
        int targetCount = target.length();
        if (fromIndex >= sourceCount) {
            return (targetCount == 0 ? sourceCount : -1);
        }
        if (fromIndex < 0) {
            fromIndex = 0;
        }
        if (targetCount == 0) {
            return fromIndex;
        }
        char[] lowerTarget = target.toLowerCase().toCharArray();
        char[] upperTarget = target.toUpperCase().toCharArray();

        int max = sourceCount - targetCount;

        for (int i = fromIndex; i <= max; i++) {
            /* Look for first character. */
            char c = source.charAt(i);
            if (c != lowerTarget[0] && c != upperTarget[0]) {
                i++;
                while (i <= max && source.charAt(i) != lowerTarget[0] && source.charAt(i) != upperTarget[0]) {
                    i++;
                }
            }

            /* Found first character, now look at the rest of v2 */
            if (i <= max) {
                int j = i + 1;
                int end = j + targetCount - 1;
                for (int k = 1; j < end; j++) {
                    char ch = source.charAt(j);
                    if (ch != lowerTarget[k] && ch != upperTarget[k]) {
                        break;
                    }
                    k++;
                }

                if (j == end) {
                    /* Found whole string. */
                    return i;
                }
            }
        }
        return -1;
    }

    @Override
    public int findFromCursor(String s) {
        if (isCountMatch()) {
            String text = getDocumentText();
            int oldIndex;
            int newIndex = 0;
            _matchCount = -1;
            do
            {
                oldIndex = newIndex;
                newIndex = (isCaseSensitive() ? text.indexOf(s, oldIndex) : indexOf(text, s, oldIndex)) + 1;
                _matchCount++;
            }
            while (newIndex > oldIndex);
        }

        if (isReverseOrder()) {
            return reverseFindFromCursor(s);
        }

        if (_component instanceof JTextComponent) {
            String text = getDocumentText();
            int selectedIndex = (getCursor() != -1 ? getCursor() : getSelectedIndex());
            if (selectedIndex < 0)
                selectedIndex = 0;
            int count = getElementCount();
            if (count == 0)
                return s.length() > 0 ? -1 : 0;

            // find from cursor
            int found = isCaseSensitive() ? text.indexOf(s, selectedIndex) : indexOf(text, s, selectedIndex);

            // if not found, start over from the beginning
            if (found == -1) {
                found = isCaseSensitive() ? text.indexOf(s, 0) : indexOf(text, s, 0);
                if (found >= selectedIndex) {
                    found = -1;
                }
            }

            return found;
        }
        else {
            return super.findFromCursor(s);
        }
    }

    @Override
    public int reverseFindFromCursor(String s) {
        if (!isReverseOrder()) {
            return findFromCursor(s);
        }

        if (_component instanceof JTextComponent) {
            String text = getDocumentText();
            int selectedIndex = (getCursor() != -1 ? getCursor() : getSelectedIndex());
            if (selectedIndex < 0)
                selectedIndex = 0;
            int count = getElementCount();
            if (count == 0)
                return s.length() > 0 ? -1 : 0;

            // find from cursor
            int found = isCaseSensitive() ? text.lastIndexOf(s, selectedIndex) : lastIndexOf(text, s, selectedIndex);

            // if not found, start over from the end
            if (found == -1) {
                found = isCaseSensitive() ? text.lastIndexOf(s, text.length() - 1) : lastIndexOf(text, s, text.length() - 1);
                if (found <= selectedIndex) {
                    found = -1;
                }
            }

            return found;
        }
        else {
            return super.findFromCursor(s);
        }
    }

    @Override
    public int findNext(String s) {
        if (_component instanceof JTextComponent) {
            String text = getDocumentText();
            int selectedIndex = (getCursor() != -1 ? getCursor() : getSelectedIndex());
            if (selectedIndex < 0)
                selectedIndex = 0;
            int count = getElementCount();
            if (count == 0)
                return s.length() > 0 ? -1 : 0;

            // find from cursor
            int found = isCaseSensitive() ? text.indexOf(s, selectedIndex + 1) : indexOf(text, s, selectedIndex + 1);

            // if not found, start over from the beginning
            if (found == -1 && isRepeats()) {
                found = isCaseSensitive() ? text.indexOf(s, 0) : indexOf(text, s, 0);
                if (found > selectedIndex) {
                    found = -1;
                }
            }

            return found;
        }
        else {
            return super.findNext(s);
        }
    }

    @Override
    public int findPrevious(String s) {
        if (_component instanceof JTextComponent) {
            String text = getDocumentText();
            int selectedIndex = (getCursor() != -1 ? getCursor() : getSelectedIndex());
            if (selectedIndex < 0)
                selectedIndex = 0;
            int count = getElementCount();
            if (count == 0)
                return s.length() > 0 ? -1 : 0;

            // find from cursor
            int found = isCaseSensitive() ? text.lastIndexOf(s, selectedIndex - 1) : lastIndexOf(text, s, selectedIndex - 1);

            // if not found, start over from the beginning
            if (found == -1 && isRepeats()) {
                found = isCaseSensitive() ? text.lastIndexOf(s, count - 1) : lastIndexOf(text, s, count - 1);
                if (found < selectedIndex) {
                    found = -1;
                }
            }

            return found;
        }
        else {
            return super.findPrevious(s);
        }
    }

    private class HighlighCache extends HashMap {
        public void addHighlight(Object obj) {
            put(obj, null);
        }

        public void removeHighlight(Object obj) {
            remove(obj);
        }

        public Iterator getAllHighlights() {
            return keySet().iterator();
        }

        public void removeAllHighlights() {
            clear();
        }
    }

    @Override
    public void hidePopup() {
        super.hidePopup();
        _selectedIndex = -1;
    }

    @Override
    protected void searchingTextEmpty() {
        setSelectedIndex(-1, false);
    }
}
