/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.netbeans.lib.editor.codetemplates;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.lang.reflect.Method;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.prefs.PreferenceChangeEvent;
import java.util.prefs.PreferenceChangeListener;
import java.util.prefs.Preferences;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.event.CaretEvent;
import javax.swing.event.CaretListener;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.event.UndoableEditEvent;
import javax.swing.event.UndoableEditListener;
import javax.swing.text.AbstractDocument;
import javax.swing.text.BadLocationException;
import javax.swing.text.Caret;
import javax.swing.text.Document;
import javax.swing.text.JTextComponent;
import javax.swing.text.Position;
import javax.swing.undo.UndoableEdit;
import org.netbeans.api.editor.mimelookup.MimeLookup;
import org.netbeans.api.editor.mimelookup.MimePath;
import org.netbeans.editor.Acceptor;
import org.netbeans.editor.AcceptorFactory;
import org.netbeans.lib.editor.codetemplates.api.CodeTemplate;
import org.netbeans.lib.editor.codetemplates.spi.CodeTemplateFilter;
import org.netbeans.lib.editor.util.swing.DocumentUtilities;
import org.netbeans.spi.editor.hints.ErrorDescription;
import org.netbeans.spi.editor.hints.ErrorDescriptionFactory;
import org.netbeans.spi.editor.hints.Fix;
import org.netbeans.spi.editor.hints.HintsController;
import org.netbeans.spi.editor.hints.Severity;
import org.openide.ErrorManager;
import org.openide.text.CloneableEditorSupport;
import org.openide.util.Lookup;
import org.openide.util.NbBundle;
import org.openide.util.RequestProcessor;
import org.openide.util.WeakListeners;


/**
 * Abbreviation detection detects typing of an abbreviation
 * in the document.
 *
 * @author Miloslav Metelka
 * @version 1.00
 */

public final class AbbrevDetection implements DocumentListener, PropertyChangeListener, KeyListener, CaretListener, PreferenceChangeListener {

    private static final Logger LOG = Logger.getLogger(AbbrevDetection.class.getName());
    private static final RequestProcessor RP = new RequestProcessor(AbbrevDetection.class.getName());
    
    /**
     * Document property which determines whether an ongoing document modification
     * should be completely ignored by the abbreviation framework.
     * <br/>
     * This is useful e.g. for code templates parameter replication.
     */
    private static final String ABBREV_IGNORE_MODIFICATION_DOC_PROPERTY
            = "abbrev-ignore-modification"; // NOI18N

    private static final String EDITING_TEMPLATE_DOC_PROPERTY = "code-template-insert-handler"; //NOI18N

    private static final String SURROUND_WITH = NbBundle.getMessage(SurroundWithFix.class, "TXT_SurroundWithHint_Label"); //NOI18N
    private static final int SURROUND_WITH_DELAY = 250;
    
    public static AbbrevDetection get(JTextComponent component) {
        AbbrevDetection ad = (AbbrevDetection)component.getClientProperty(AbbrevDetection.class);
        if (ad == null) {
            ad = new AbbrevDetection(component);
            component.putClientProperty(AbbrevDetection.class, ad);
        }
        return ad;
    }
    
    public static synchronized void remove(JTextComponent component) {
        AbbrevDetection ad = (AbbrevDetection)component.getClientProperty(AbbrevDetection.class);
        if (ad != null) {
            assert ad.component == component : "Wrong component: AbbrevDetection.component=" + ad.component + ", component=" + component;
            ad.uninstall();
            component.putClientProperty(AbbrevDetection.class, null);
        }
    }
    
    private JTextComponent component;
    
    /** Document for which this abbreviation detection was constructed. */
    private Document doc;
    private DocumentListener weakDocL;
    
    /**
     * Offset after the last typed character of the collected abbreviation.
     */
    private Position abbrevEndPosition;

    /**
     * Abbreviation characters captured from typing.
     */
    private final StringBuffer abbrevChars = new StringBuffer();

//    /** Chars on which to expand acceptor */
//    private Acceptor expandAcceptor;

    /** Which chars reset abbreviation accounting */
    private Acceptor resetAcceptor;
    
    private MimePath mimePath = null;
    private Preferences prefs = null;
    private PreferenceChangeListener weakPrefsListener = null;
    
    private ErrorDescription errorDescription = null;
    private List<Fix> surrounsWithFixes = null;
    private Timer surroundsWithTimer;
    
    private AbbrevDetection(JTextComponent component) {
        this.component = component;
        component.addCaretListener(this);
        doc = component.getDocument();
        if (doc != null) {
            listenOnDoc();
        }

        String mimeType = DocumentUtilities.getMimeType(component);
        if (mimeType != null) {
            mimePath = MimePath.parse(mimeType);
            prefs = MimeLookup.getLookup(mimePath).lookup(Preferences.class);
            prefs.addPreferenceChangeListener(WeakListeners.create(PreferenceChangeListener.class, this, prefs));
        }
        
        // Load the settings
        preferenceChange(null);
        
        component.addKeyListener(this);
        component.addPropertyChangeListener(this);
        
        surroundsWithTimer = new Timer(0, new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                // #124515, give up when the document is locked otherwise we are likely
                // to cause a deadlock.
                if (!DocumentUtilities.isReadLocked(doc)) {
                    showSurroundWithHint();
                }
            }
        });
        surroundsWithTimer.setRepeats(false);
    }
    
    private void listenOnDoc() {
        weakDocL = WeakListeners.document(this, doc);
        doc.addDocumentListener(weakDocL);
    }

    private void uninstall() {
        assert component != null : "Can't call uninstall before the construction finished";
        component.removeCaretListener(this);
        if (doc != null) {
            listenOnDoc();
        }

        component.removeKeyListener(this);
        component.removePropertyChangeListener(this);
        surroundsWithTimer.stop();
    }
    
    public void preferenceChange(PreferenceChangeEvent evt) {
        String settingName = evt == null ? null : evt.getKey();
        if (settingName == null || "abbrev-reset-acceptor".equals(settingName)) { //NOI18N
            resetAcceptor = getResetAcceptor(prefs, mimePath);
        }
    }
    
    public void insertUpdate(DocumentEvent evt) {
        if (!isIgnoreModification()) {
            if (DocumentUtilities.isTypingModification(evt.getDocument()) && !isAbbrevDisabled()) {
                int offset = evt.getOffset();
                int length = evt.getLength();
                appendTypedText(offset, length);
            } else { // not typing modification -> reset abbreviation collecting
                resetAbbrevChars();
            }
        }
    }

    public void removeUpdate(DocumentEvent evt) {
        if (!isIgnoreModification()) {
            if (DocumentUtilities.isTypingModification(evt.getDocument()) && !isAbbrevDisabled()) {
                int offset = evt.getOffset();
                int length = evt.getLength();
                removeAbbrevText(offset, length);
            } else { // not typing modification -> reset abbreviation collecting
                resetAbbrevChars();
            }
        }
    }

    public void changedUpdate(DocumentEvent evt) {
    }

    public void propertyChange(PropertyChangeEvent evt) {
        if ("document".equals(evt.getPropertyName())) { //NOI18N
            if (doc != null && weakDocL != null) {
                doc.removeDocumentListener(weakDocL);
                weakDocL = null;
            }
            
            doc = component.getDocument();
            if (doc != null) {
                listenOnDoc();
            }

            // unregister and destroy the old preferences (if we have any)
            if (prefs != null) {
                prefs.removePreferenceChangeListener(weakPrefsListener);
                prefs = null;
                weakPrefsListener = null;
                mimePath = null;
            }
            
            // load and hook up to preferences for the new mime type
            String mimeType = DocumentUtilities.getMimeType(component);
            if (mimeType != null) {
                mimePath = MimePath.parse(mimeType);
                prefs = MimeLookup.getLookup(mimePath).lookup(Preferences.class);
                weakPrefsListener = WeakListeners.create(PreferenceChangeListener.class, this, prefs);
                prefs.addPreferenceChangeListener(weakPrefsListener);
            }
            
            // reload the settings
            preferenceChange(null);
        }
    }
    
    public void keyPressed(KeyEvent evt) {
        checkExpansionKeystroke(evt);
    }
    
    public void keyReleased(KeyEvent evt) {
        checkExpansionKeystroke(evt);
    }
    
    public void keyTyped(KeyEvent evt) {
        checkExpansionKeystroke(evt);
    }
    
    public void caretUpdate(CaretEvent evt) {
        if (evt.getDot() != evt.getMark()) {
            surroundsWithTimer.setInitialDelay(SURROUND_WITH_DELAY);
            surroundsWithTimer.restart();
        } else {
            surroundsWithTimer.stop();
            hideSurroundWithHint();
        }
    }

    private boolean isIgnoreModification() {
        return Boolean.TRUE.equals(doc.getProperty(ABBREV_IGNORE_MODIFICATION_DOC_PROPERTY));
    }
    
    private boolean isAbbrevDisabled() {
        return org.netbeans.editor.Abbrev.isAbbrevDisabled(component);
    }
    
    private void checkExpansionKeystroke(KeyEvent evt) {
        Position pos = null;
        Document d = null;
        synchronized (abbrevChars) {
            if (abbrevEndPosition != null && component != null && doc != null
                && component.getCaretPosition() == abbrevEndPosition.getOffset()
                && !isAbbrevDisabled()
                && doc.getProperty(EDITING_TEMPLATE_DOC_PROPERTY) == null
            ) {
                pos = abbrevEndPosition;
                d = component.getDocument();
            }
        }
        if (pos != null && d != null) {
            CodeTemplateManagerOperation operation = CodeTemplateManagerOperation.get(d, pos.getOffset());
            if (operation != null) {
                KeyStroke expandKeyStroke = operation.getExpansionKey();

                if (expandKeyStroke.equals(KeyStroke.getKeyStrokeForEvent(evt))) {
                    if (expand(operation)) {
                        evt.consume();
                    }
                }
            }
        }
    }

    /**
     * Get current abbreviation string.
     */
    private CharSequence getAbbrevText() {
        return abbrevChars;
    }

    /**
     * Reset abbreviation string collecting.
     */
    private void resetAbbrevChars() {
        synchronized(abbrevChars) {
            abbrevChars.setLength(0);
            abbrevEndPosition = null;
        }
    }
    
    private void appendTypedText(int offset, int insertLength) {
        if (abbrevEndPosition == null
            || offset + insertLength != abbrevEndPosition.getOffset()
        ) {
            // Does not follow previous insert
            resetAbbrevChars();
        }

        if (abbrevEndPosition == null) { // starting the new string
            try {
                // Start new accounting if previous char would reset abbrev
                // i.e. check that not start typing 'u' after existing 'p' which would
                // errorneously expand to 'public'
                if (offset == 0
                        || resetAcceptor.accept(DocumentUtilities.getText(doc, offset - 1, 1).charAt(0))
                ) {
                    abbrevEndPosition = doc.createPosition(offset + insertLength);
                }
            } catch (BadLocationException e) {
                ErrorManager.getDefault().notify(e);
            }
        }

        if (abbrevEndPosition != null) {
            try {
                String typedText = doc.getText(offset, insertLength); // typically just one char
                boolean textAccepted = true;
                for (int i = typedText.length() - 1; i >= 0; i--) {
                    if (resetAcceptor.accept(typedText.charAt(i))) {
                        // In theory there could be more than one character in the typed text
                        // and the resetting could occur on the very first char
                        // the next chars would not be accumulated as the insert
                        // is treated as a batch.
                        textAccepted = false;
                        break;
                    }
                }
                
                if (textAccepted) {
                    abbrevChars.append(typedText);
                    // abbrevEndPosition should move appropriately
                } else {
                    resetAbbrevChars();
                }

            } catch (BadLocationException e) {
                ErrorManager.getDefault().notify(e);
                resetAbbrevChars();
            }
        }
    }
    
    private void removeAbbrevText(int offset, int removeLength) {
        synchronized(abbrevChars) {
            if (abbrevEndPosition != null) {
                // Abbrev position should already move appropriately
                if (offset == abbrevEndPosition.getOffset()
                    && abbrevChars.length() >= removeLength
                ) { // removed at end
                    abbrevChars.setLength(abbrevChars.length() - removeLength);

                } else {
                    resetAbbrevChars();
                }
            }
        }
    }

    public boolean expand(CodeTemplateManagerOperation op) {
        CharSequence abbrevText = getAbbrevText();
        int abbrevEndOffset = abbrevEndPosition.getOffset();
        if (expand(op, component, abbrevEndOffset - abbrevText.length(), abbrevText)) {
            resetAbbrevChars();
            return true;
        } else {
            return false;
        }
    }
    
    private void showSurroundWithHint() {
        try {
            final Caret caret = component.getCaret();
            if (caret != null) {
                final Position pos = doc.createPosition(caret.getDot());
                RP.post(new Runnable() {
                    public void run() {
                        final List<Fix> fixes = SurroundWithFix.getFixes(component);
                        SwingUtilities.invokeLater(new Runnable() {
                            public void run() {
                                if (!fixes.isEmpty()) {
                                    errorDescription = ErrorDescriptionFactory.createErrorDescription(
                                            Severity.HINT, SURROUND_WITH, surrounsWithFixes = fixes, doc, pos, pos);

                                    HintsController.setErrors(doc, SURROUND_WITH, Collections.singleton(errorDescription));
                                } else {
                                    hideSurroundWithHint();
                                }
                            }
                        });
                    }
                });
            }
        } catch (BadLocationException ble) {
            Logger.getLogger("global").log(Level.WARNING, ble.getMessage(), ble);
        }
    }

    private void hideSurroundWithHint() {
        if (surrounsWithFixes != null)
            surrounsWithFixes = null;
        if (errorDescription != null) {
            errorDescription = null;
            HintsController.setErrors(doc, SURROUND_WITH, Collections.<ErrorDescription>emptySet());
        }
    }

    public static Acceptor getResetAcceptor(Preferences prefs, MimePath mimePath) {
        return prefs != null ? (Acceptor) callFactory(prefs, mimePath, "abbrev-reset-acceptor", AcceptorFactory.WHITESPACE) : AcceptorFactory.WHITESPACE; //NOI18N
    }

    // copied from org.netbeans.modules.editor.lib.SettingsConversions
    private static Object callFactory(Preferences prefs, MimePath mimePath, String settingName, Object defaultValue) {
        String factoryRef = prefs.get(settingName, null);
        
        if (factoryRef != null) {
            int lastDot = factoryRef.lastIndexOf('.'); //NOI18N
            assert lastDot != -1 : "Need fully qualified name of class with the static setting factory method."; //NOI18N

            String classFqn = factoryRef.substring(0, lastDot);
            String methodName = factoryRef.substring(lastDot + 1);

            ClassLoader loader = Lookup.getDefault().lookup(ClassLoader.class);
            try {
                Class factoryClass = loader.loadClass(classFqn);
                Method factoryMethod;
                
                try {
                    // normally the method should accept mime path and the a setting name
                    factoryMethod = factoryClass.getDeclaredMethod(methodName, MimePath.class, String.class);
                } catch (NoSuchMethodException nsme) {
                    // but there might be methods that don't need those params
                    try {
                        factoryMethod = factoryClass.getDeclaredMethod(methodName);
                    } catch (NoSuchMethodException nsme2) {
                        // throw the first exception complaining about the full signature
                        throw nsme;
                    }
                }
                
                Object value;
                if (factoryMethod.getParameterTypes().length == 2) {
                    value = factoryMethod.invoke(null, mimePath, settingName);
                } else {
                    value = factoryMethod.invoke(null);
                }
                
                if (value != null) {
                    return value;
                }
            } catch (Exception e) {
                LOG.log(Level.WARNING, null, e);
            }
        }

        return defaultValue;
    }

    private static boolean expand(CodeTemplateManagerOperation op, JTextComponent component, int abbrevStartOffset, CharSequence abbrev) {
        op.waitLoaded();
        CodeTemplate ct = op.findByAbbreviation(abbrev.toString());
        if (ct != null) {
            if (accept(ct, CodeTemplateManagerOperation.getTemplateFilters(component, abbrevStartOffset))) {
                Document doc = component.getDocument();
                sendUndoableEdit(doc, CloneableEditorSupport.BEGIN_COMMIT_GROUP);
                try {
                    // Remove the abbrev text
                    doc.remove(abbrevStartOffset, abbrev.length());
                    ct.insert(component);
                } catch (BadLocationException ble) {
                } finally {
                    sendUndoableEdit(doc, CloneableEditorSupport.END_COMMIT_GROUP);
                }
                return true;
            }
        }
        return false;
    }

    private static boolean accept(CodeTemplate template, Collection<? extends CodeTemplateFilter> filters) {
        for(CodeTemplateFilter filter : filters) {
            if (!filter.accept(template)) {
                return false;
            }
        }
        return true;
    }

    private static void sendUndoableEdit(Document d, UndoableEdit ue) {
        if(d instanceof AbstractDocument) {
            UndoableEditListener[] uels = ((AbstractDocument)d).getUndoableEditListeners();
            UndoableEditEvent ev = new UndoableEditEvent(d, ue);
            for(UndoableEditListener uel : uels) {
                uel.undoableEditHappened(ev);
            }
        }
    }
}