/*
 * @(#)AbstractIntelliHints.java 7/24/2005
 *
 * Copyright 2002 - 2005 JIDE Software Inc. All rights reserved.
 */
package com.jidesoft.hints;

import com.jidesoft.plaf.UIDefaultsLookup;
import com.jidesoft.popup.JidePopup;
import com.jidesoft.swing.DelegateAction;

import javax.swing.*;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.event.PopupMenuEvent;
import javax.swing.event.PopupMenuListener;
import javax.swing.text.BadLocationException;
import javax.swing.text.JTextComponent;
import java.awt.*;
import java.awt.event.*;
import java.util.ArrayList;
import java.util.List;


/**
 * <code>AbstractIntelliHints</code> is an abstract implementation of {@link com.jidesoft.hints.IntelliHints}. It covers
 * functions such as showing the hint popup at the correct position, delegating keystrokes, updating and selecting hint.
 * The only thing that is left out to subclasses is the creation of the hint popup.
 *
 * @author Santhosh Kumar T
 * @author JIDE Software, Inc.
 */
public abstract class AbstractIntelliHints implements IntelliHints {

    private JidePopup _popup;
    private JTextComponent _textComponent;
    private JComponent _hintsComponent;

    private boolean _followCaret = false;

    // we use this flag to workaround the bug that setText() will trigger the hint popup.
    private boolean _keyTyped = false;

    // Specifies whether the hints popup should be displayed automatically.
    // Default is true for backward compatibility.
    private boolean _autoPopup = true;
    private int _showHintsDelay = 200;
    private List<KeyStroke> _showHintsKeyStrokes;
    private DelegateAction _showAction;

    /**
     * Creates an IntelliHints object for a given JTextComponent.
     *
     * @param textComponent the text component.
     */
    public AbstractIntelliHints(JTextComponent textComponent) {
        _textComponent = textComponent;
        getTextComponent().putClientProperty(CLIENT_PROPERTY_INTELLI_HINTS, this);

        _popup = createPopup();

        getTextComponent().getDocument().addDocumentListener(documentListener);
        getTextComponent().addKeyListener(new KeyListener() {
            public void keyTyped(KeyEvent e) {
            }

            public void keyPressed(KeyEvent e) {
            }

            public void keyReleased(KeyEvent e) {
                if (KeyEvent.VK_ESCAPE != e.getKeyCode() && KeyEvent.VK_ENTER != e.getKeyCode()) {
                    setKeyTyped(true);
                }
            }
        });
        getTextComponent().addFocusListener(new FocusListener() {
            public void focusGained(FocusEvent e) {
            }

            public void focusLost(FocusEvent e) {
                Container topLevelAncestor = _popup.getTopLevelAncestor();
                if (topLevelAncestor == null) {
                    return;
                }
                Component oppositeComponent = e.getOppositeComponent();
                if (topLevelAncestor == oppositeComponent || topLevelAncestor.isAncestorOf(oppositeComponent)) {
                    return;
                }
                hideHintsPopup();
            }
        });

        _showAction = new DelegateAction() {
            private static final long serialVersionUID = 2243999895981912016L;

            @Override
            public boolean delegateActionPerformed(ActionEvent e) {
                JComponent tf = (JComponent) e.getSource();
                IntelliHints hints = getIntelliHints(tf);
                if (hints instanceof AbstractIntelliHints) {
                    AbstractIntelliHints aih = (AbstractIntelliHints) hints;
                    if (tf.isEnabled() && !aih.isHintsPopupVisible()) {
                        aih.showHintsPopup();
                        return true;
                    }
                }
                return false;
            }

            @Override
            public boolean isDelegateEnabled() {
                return !isHintsPopupVisible();
            }
        };
        addShowHintsKeyStroke(getShowHintsKeyStroke());

        KeyStroke[] keyStrokes = getDelegateKeyStrokes();
        for (KeyStroke keyStroke : keyStrokes) {
            DelegateAction.replaceAction(getTextComponent(), JComponent.WHEN_FOCUSED, keyStroke, new LazyDelegateAction(keyStroke));
        }
    }

    protected JidePopup createPopup() {
        JidePopup popup = com.jidesoft.popup.JidePopupFactory.getSharedInstance().createPopup();
        popup.setLayout(new BorderLayout());
        popup.setResizable(true);
        popup.setPopupBorder(BorderFactory.createLineBorder(UIDefaultsLookup.getColor("controlDkShadow"), 1));
        popup.setMovable(false);
        popup.addPopupMenuListener(new PopupMenuListener() {
            public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
            }

            public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
                DelegateAction.restoreAction(getTextComponent(), JComponent.WHEN_FOCUSED, KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), hideAction);
                DelegateAction.restoreAction(getTextComponent(), JComponent.WHEN_FOCUSED, KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), acceptAction);
            }

            public void popupMenuCanceled(PopupMenuEvent e) {
            }
        });
        popup.setTransient(true);
        popup.setKeepPreviousSize(false);
        popup.setReturnFocusToOwner(false);
        return popup;
    }

    public JTextComponent getTextComponent() {
        return _textComponent;
    }


    /**
     * After user has selected a item in the hints popup, this method will update JTextComponent accordingly to accept
     * the hint.
     * <p/>
     * For JTextArea, the default implementation will insert the hint into current caret position. For JTextField, by
     * default it will replace the whole content with the item user selected. Subclass can always choose to override it
     * to accept the hint in a different way. For example, {@link com.jidesoft.hints.FileIntelliHints} will append the
     * selected item at the end of the existing text in order to complete a full file path.
     */
    public void acceptHint(Object selected) {
        if (selected == null)
            return;

        String newText;
        int pos = getTextComponent().getCaretPosition();
        if (isMultilineTextComponent()) {
            String text = getTextComponent().getText();
            int start = text.lastIndexOf('\n', pos - 1);
            String remain = pos == -1 ? "" : text.substring(pos);
            text = text.substring(0, start + 1);
            text += selected;
            text += remain;
            newText = text;
        }
        else {
            newText = selected.toString();
        }

        getTextComponent().setText(newText);
        // DocumentFilters in JTextComponent's document model may alter the
        // provided text. The line separator has to be searched in the actual text
        String actualText = getTextComponent().getText();
        int separatorIndex = actualText.indexOf('\n', pos);
        getTextComponent().setCaretPosition(
                separatorIndex == -1 ? actualText.length() : separatorIndex);
    }

    /**
     * Returns whether this IntelliHints' <code>JTextComponent</code> supports single-line text or multi-line text.
     *
     * @return <code>true</code> if the component supports multiple text lines, <code>false</code> otherwise
     */
    protected boolean isMultilineTextComponent() {
        return getTextComponent() instanceof JTextArea ||
                getTextComponent() instanceof JEditorPane;
    }

    /**
     * This method will call {@link #showHints()} if and only if the text component is enabled and has focus.
     */
    protected void showHintsPopup() {
        if (!getTextComponent().isEnabled() || !getTextComponent().isEditable() || !getTextComponent().hasFocus()) {
            return;
        }
        showHints();
    }

    /**
     * Shows the hints popup which contains the hints. It will call {@link #updateHints(Object)}. Only if it returns
     * true, the popup will be shown. You can call this method to fore the hints to be displayed.
     */
    public void showHints() {
        if (_popup == null) {
            return;
        }
        if (_hintsComponent == null) {
            _hintsComponent = createHintsComponent();
            _popup.add(_hintsComponent);
            getDelegateComponent().setRequestFocusEnabled(false);
            getDelegateComponent().addMouseListener(new MouseAdapter() {
                @Override
                public void mouseClicked(MouseEvent e) {
                    hideHintsPopup();
                    setHintsEnabled(false);
                    acceptHint(getSelectedHint());
                    setHintsEnabled(true);
                }
            });
        }
        if (updateHints(getContext())) {
            if (!isHintsPopupVisible()) {
                DelegateAction.replaceAction(getTextComponent(), JComponent.WHEN_FOCUSED, KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), hideAction);
                DelegateAction.replaceAction(getTextComponent(), JComponent.WHEN_FOCUSED, KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), acceptAction, true);
            }

            int x = 0;
            int y = 0;
            int height = 0;

            try {
                int pos = getCaretPositionForPopup();
                Rectangle position = getCaretRectangleForPopup(pos);
                y = position.y;
                x = position.x;
                height = position.height;
            }
            catch (BadLocationException e) {
                // this should never happen!!!
            }

            _popup.setOwner(getTextComponent());
            _popup.showPopup(new Insets(y, x, getTextComponent().getHeight() - height - y, 0));
        }
        else {
            _popup.hidePopup();
        }
    }

    /**
     * Gets the caret rectangle where caret is displayed. The popup will be show around the area so that the returned
     * rectangle area is always visible. This method will be called twice.
     *
     * @param caretPosition the caret position.
     *
     * @return the popup position relative to the text component. <br>Please note, this position is actually a rectangle
     *         area. The reason is the popup could be shown below or above the rectangle. Usually, the popup will be
     *         shown below the rectangle. In this case, the x and y of the rectangle will be the top-left corner of the
     *         popup. However if there isn't enough space for the popup because it's close to screen bottom border, we
     *         will show the popup above the rectangle. In this case, the bottom-left corner of the popup will be at x
     *         and (y - height). Simply speaking, the popup will never cover the area specified by the rectangle (either
     *         below it or above it).
     *
     * @throws BadLocationException if the given position does not represent a valid location in the associated
     *                              document.
     */
    protected Rectangle getCaretRectangleForPopup(int caretPosition) throws BadLocationException {
        return getTextComponent().getUI().modelToView(getTextComponent(), caretPosition);
    }


    /**
     * Gets the caret position which is used as the anchor point to display the popup. By default, it {@link
     * #isFollowCaret()} is true, it will return caret position. Otherwise it will return the caret position at the
     * beginning of the caret line. Subclass can override to return any caret position.
     *
     * @return the caret position which is used as the anchor point to display the popup.
     */
    protected int getCaretPositionForPopup() {
        int caretPosition = Math.min(getTextComponent().getCaret().getDot(), getTextComponent().getCaret().getMark());
        if (isFollowCaret()) {
            return caretPosition;
        }
        else {
            try {
                Rectangle viewRect = getTextComponent().getUI().modelToView(getTextComponent(), caretPosition);
                viewRect.x = 0;
                return getTextComponent().getUI().viewToModel(getTextComponent(), viewRect.getLocation());
            }
            catch (BadLocationException e) {
                return 0;
            }
        }
    }

    /**
     * Gets the context for hints. The context is the information that IntelliHints needs in order to generate a list of
     * hints. For example, for code-completion, the context is current word the cursor is on. for file completion, the
     * context is the full string starting from the file system root. <p>We provide a default context in
     * AbstractIntelliHints. If it's a JTextArea, the context will be the string at the caret line from line beginning
     * to the caret position. If it's a JTextField, the context will be whatever string in the text field. Subclass can
     * always override it to return the context that is appropriate.
     *
     * @return the context.
     */
    protected Object getContext() {
        if (isMultilineTextComponent()) {
            int pos = getTextComponent().getCaretPosition();
            if (pos == 0) {
                return "";
            }
            else {
                String text = getTextComponent().getText();
                int start = text.lastIndexOf('\n', pos - 1);
                return text.substring(start + 1, pos);
            }
        }
        else {
            return getTextComponent().getText();
        }
    }

    /**
     * Hides the hints popup.
     */
    protected void hideHintsPopup() {
        if (_popup != null) {
            _popup.hidePopup();
        }
        setKeyTyped(false);
    }

    /**
     * Enables or disables the hints popup.
     *
     * @param enabled true to enable the hints popup. Otherwise false.
     */
    public void setHintsEnabled(boolean enabled) {
        if (!enabled) {
            // disable show hint temporarily
            getTextComponent().getDocument().removeDocumentListener(documentListener);
        }
        else {
            // enable show hint again
            getTextComponent().getDocument().addDocumentListener(documentListener);
        }

    }

    /**
     * Checks if the hints popup is visible.
     *
     * @return true if it's visible. Otherwise, false.
     */
    public boolean isHintsPopupVisible() {
        return _popup != null && _popup.isPopupVisible();
    }

    /**
     * Should the hints popup follows the caret.
     *
     * @return true if the popup shows up right below the caret. False if the popup always shows at the bottom-left
     *         corner (or top-left if there isn't enough on the bottom of the screen) of the JTextComponent.
     */
    public boolean isFollowCaret() {
        return _followCaret;
    }

    /**
     * Sets the position of the hints popup. If followCaret is true, the popup shows up right below the caret.
     * Otherwise, it will stay at the bottom-left corner (or top-left if there isn't enough on the bottom of the screen)
     * of JTextComponent.
     *
     * @param followCaret true or false.
     */
    public void setFollowCaret(boolean followCaret) {
        _followCaret = followCaret;
    }

    /**
     * Returns whether the hints popup is automatically displayed. Default is true
     *
     * @return true if the popup should be automatically displayed. False will never show it automatically and then need
     *         the user to manually activate it via the getShowHintsKeyStroke() key binding.
     *
     * @see #setAutoPopup(boolean)
     */
    public boolean isAutoPopup() {
        return _autoPopup;
    }

    /**
     * Sets whether the popup should be displayed automatically. If autoPopup is true then is the popup automatically
     * displayed whenever updateHints() return true. If autoPopup is false it's not automatically displayed and will
     * need the user to activate the key binding defined by getShowHintsKeyStroke().
     *
     * @param autoPopup true or false
     */
    public void setAutoPopup(boolean autoPopup) {
        this._autoPopup = autoPopup;
    }

    /**
     * Gets the delegate keystrokes.
     * <p/>
     * When hint popup is visible, the keyboard focus never leaves the text component. However the hint popup usually
     * contains a component that user will try to use navigation key to select an item. For example, use UP and DOWN key
     * to navigate the list. Those keystrokes, if the popup is visible, will be delegated to the the component that
     * returns from {@link #getDelegateComponent()}.
     * <p/>
     * NOTE: Since this method would be invoked inside the constructor of AbstractIntelliHints, please do not try to
     * return a field because the field is not initiated yet at this time.
     *
     * @return an array of keystrokes that will be delegate to {@link #getDelegateComponent()} when hint popup is
     *         shown.
     */
    abstract protected KeyStroke[] getDelegateKeyStrokes();

    /**
     * Gets the delegate component in the hint popup.
     *
     * @return the component that will receive the keystrokes that are delegated to hint popup.
     */
    abstract protected JComponent getDelegateComponent();

    /**
     * Gets the keystroke that will trigger the hint popup. Usually the hints popup will be shown automatically when
     * user types. Only when the hint popup is hidden accidentally, this keystroke will show the popup again.
     * <p/>
     * By default, it's the DOWN key for JTextField and CTRL+SPACE for JTextArea.
     *
     * @return the keystroke that will trigger the hint popup.
     */
    protected KeyStroke getShowHintsKeyStroke() {
        if (isMultilineTextComponent()) {
            return KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, KeyEvent.CTRL_MASK);
        }
        else {
            return KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0);
        }
    }

    private DelegateAction acceptAction = new DelegateAction() {
        private static final long serialVersionUID = -2516216121942080133L;

        @Override
        public boolean isDelegateEnabled() {
            return isHintsPopupVisible() && getSelectedHint() != null;
        }

        @Override
        public boolean delegateActionPerformed(ActionEvent e) {
            JComponent tf = (JComponent) e.getSource();
            IntelliHints hints = getIntelliHints(tf);
            if (hints instanceof AbstractIntelliHints) {
                AbstractIntelliHints aih = (AbstractIntelliHints) hints;
                aih.hideHintsPopup();
                if (aih.getSelectedHint() != null) {
                    aih.setHintsEnabled(false);
                    aih.acceptHint(hints.getSelectedHint());
                    aih.setHintsEnabled(true);
                    return true;
                }
            }
            return false;
        }
    };

    private DelegateAction hideAction = new DelegateAction() {
        private static final long serialVersionUID = 1921213578011852535L;

        @Override
        public boolean isDelegateEnabled() {
            return _textComponent.isEnabled() && isHintsPopupVisible();
        }

        @Override
        public boolean delegateActionPerformed(ActionEvent e) {
            if (isEnabled()) {
                hideHintsPopup();
                return true;
            }
            return false;
        }
    };

    private DocumentListener documentListener = new DocumentListener() {
        private Timer timer = new Timer(getShowHintsDelay(), new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                if (isKeyTyped()) {
                    if (isHintsPopupVisible() || isAutoPopup()) {
                        showHintsPopup();
                    }
                    setKeyTyped(false);
                }
            }
        });

        public void insertUpdate(DocumentEvent e) {
            startTimer();
        }

        public void removeUpdate(DocumentEvent e) {
            startTimer();
        }

        public void changedUpdate(DocumentEvent e) {
        }

        void startTimer() {
            if (timer.isRunning()) {
                timer.setInitialDelay(getShowHintsDelay());
                timer.setDelay(getShowHintsDelay());
                timer.restart();
            }
            else {
                timer.setRepeats(false);
                timer.setInitialDelay(getShowHintsDelay());
                timer.setDelay(getShowHintsDelay());
                timer.start();
            }
        }
    };

    private boolean isKeyTyped() {
        return _keyTyped;
    }

    private void setKeyTyped(boolean keyTyped) {
        _keyTyped = keyTyped;
    }

    /**
     * Gets the delay after the key is pressed to show hints.
     *
     * @return the delay time on milliseconds.
     *
     * @see #setShowHintsDelay(int)
     */
    public int getShowHintsDelay() {
        return _showHintsDelay;
    }

    /**
     * Sets the delay after the key is pressed to show hints.
     * <p/>
     * By default, the delay time is 200ms.
     *
     * @param showHintsDelay the delay time
     */
    public void setShowHintsDelay(int showHintsDelay) {
        _showHintsDelay = showHintsDelay;
    }

    /**
     * Adds a new key stroke to show hints popup.
     *
     * @param keyStroke the key stroke
     *
     * @see #removeShowHintsKeyStroke(javax.swing.KeyStroke)
     * @see #getAllShowHintsKeyStrokes()
     * @since 3.2.2
     */
    public void addShowHintsKeyStroke(KeyStroke keyStroke) {
        if (_showHintsKeyStrokes == null) {
            _showHintsKeyStrokes = new ArrayList<KeyStroke>();
        }
        _showHintsKeyStrokes.add(keyStroke);
        DelegateAction.replaceAction(getTextComponent(), JComponent.WHEN_FOCUSED, keyStroke, _showAction);
    }

    /**
     * Removes a key stroke from the list to show hints popup.
     *
     * @param keyStroke the key stroke
     *
     * @since 3.2.2
     */
    public void removeShowHintsKeyStroke(KeyStroke keyStroke) {
        if (_showHintsKeyStrokes != null) {
            _showHintsKeyStrokes.remove(keyStroke);
            DelegateAction.restoreAction(getTextComponent(), JComponent.WHEN_FOCUSED, keyStroke, _showAction);
        }
    }

    /**
     * Gets all key strokes that will show hints popup.
     *
     * @return the key stroke array.
     *
     * @since 3.2.2
     */
    public KeyStroke[] getAllShowHintsKeyStrokes() {
        if (_showHintsKeyStrokes == null) {
            return new KeyStroke[0];
        }
        return _showHintsKeyStrokes.toArray(new KeyStroke[_showHintsKeyStrokes.size()]);
    }

    private class LazyDelegateAction extends DelegateAction {
        private KeyStroke _keyStroke;
        private static final long serialVersionUID = -5799290233797844786L;

        public LazyDelegateAction(KeyStroke keyStroke) {
            _keyStroke = keyStroke;
        }

        @Override
        public boolean isDelegateEnabled() {
            Action action = getHintsPopupAction();
            return action != null && action.isEnabled();
        }

        private Action getHintsPopupAction() {
            if (isHintsPopupVisible() && getDelegateComponent() != null) {
                Object key = getDelegateComponent().getInputMap().get(_keyStroke);
                key = key == null ? getTextComponent().getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).get(_keyStroke) : key;
                if (key != null) {
                    return getDelegateComponent().getActionMap().get(key);
                }
            }
            return null;
        }

        @Override
        public boolean delegateActionPerformed(ActionEvent e) {
            JComponent tf = (JComponent) e.getSource();
            IntelliHints hints = getIntelliHints(tf);
            if (hints instanceof AbstractIntelliHints) {
                AbstractIntelliHints aih = (AbstractIntelliHints) hints;
                if (tf.isEnabled()) {
                    if (aih.isHintsPopupVisible()) {
                        Object key = aih.getDelegateComponent().getInputMap().get(_keyStroke);
                        key = key == null ? aih.getTextComponent().getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).get(_keyStroke) : key;
                        if (key != null) {
                            Object action = aih.getDelegateComponent().getActionMap().get(key);
                            if (action instanceof Action) {
                                ((Action) action).actionPerformed(new ActionEvent(aih.getDelegateComponent(), 0, "" + key));
                                return true;
                            }
                        }
                    }
                }
            }
            return false;
        }
    }

    /**
     * Gets the IntelliHints object if it was installed on the component before.
     *
     * @param component the component that has IntelliHints installed
     *
     * @return the IntelliHints.
     */
    public static IntelliHints getIntelliHints(JComponent component) {
        return (IntelliHints) component.getClientProperty(CLIENT_PROPERTY_INTELLI_HINTS);
    }
}
