/*
 * 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.modules.jshell.editor;

import com.sun.source.util.Trees;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.ActionEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import javax.swing.Action;
import javax.swing.ImageIcon;
import javax.swing.JComponent;
import javax.swing.JEditorPane;
import javax.swing.JLabel;
import javax.swing.JLayeredPane;
import javax.swing.SwingUtilities;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.BadLocationException;
import javax.swing.text.Caret;
import javax.swing.text.Document;
import javax.swing.text.Position;
import org.netbeans.api.editor.caret.MoveCaretsOrigin;
import org.netbeans.api.editor.document.LineDocument;
import org.netbeans.api.editor.document.LineDocumentUtils;
import org.netbeans.modules.java.preprocessorbridge.spi.WrapperFactory;
import org.netbeans.modules.jshell.env.JShellEnvironment;
import org.netbeans.modules.jshell.env.ShellEvent;
import org.netbeans.modules.jshell.env.ShellListener;
import org.netbeans.modules.jshell.env.ShellRegistry;
import org.netbeans.modules.jshell.env.ShellStatus;
import org.netbeans.modules.jshell.model.ConsoleEvent;
import org.netbeans.modules.jshell.model.ConsoleListener;
import org.netbeans.modules.jshell.model.ConsoleModel;
import org.netbeans.modules.jshell.model.ConsoleSection;
import org.netbeans.modules.jshell.model.Rng;
import org.netbeans.modules.jshell.support.ShellSession;
import org.netbeans.spi.editor.caret.CascadingNavigationFilter;
import org.netbeans.spi.editor.caret.NavigationFilterBypass;
import org.openide.nodes.Node;
import org.openide.text.CloneableEditor;
import org.openide.text.CloneableEditorSupport;
import org.openide.util.Exceptions;
import org.openide.util.Lookup;
import org.openide.util.NbBundle;
import org.openide.windows.TopComponent;

/**
 *
 * @author sdedic
 */
@TopComponent.Description(
        preferredID = "JShellEditor",
        iconBase = "org/netbeans/modules/jshell/resources/jshell-terminal.png",
        persistenceType = TopComponent.PERSISTENCE_NEVER
)
@TopComponent.Registration(mode = "editor", openAtStartup = false)
public class ConsoleEditor extends CloneableEditor {

    private ShellSession session;
    private CL cl;
    private Lookup lookup;

    public ConsoleEditor(CloneableEditorSupport support, Lookup lookup) {
        super(support);
        this.lookup = lookup;
    }

    @Override
    protected void componentOpened() {
        super.componentOpened();
    }
    
    @Override
    protected void componentShowing() {
        super.componentShowing();
        if (session == null) {
            Node n = lookup.lookup(Node.class);
            if (n != null) {
                getLookup(); // will initialize Lookup
                setActivatedNodes(new Node[] { n });
            }
            initialize();
            if (session == null && pane != null) {
                pane.addPropertyChangeListener("document", 
                        (e) -> initialize());
            }
        } else {
            updateHourglass();
        }
    }
    

    @Override
    protected void componentHidden() {
        removeProgressIndicator();
        super.componentHidden();
    }
    
    private void updateHourglass() {
        ShellStatus status = env.getStatus();

        if (status == ShellStatus.STARTING) {
            showProgressIndicator(prepareInitPanel(), 0, null);
            return;
        } else if (status == ShellStatus.EXECUTE) {
            String label = env.getSession().getExecutionLabel();
            ConsoleModel model = session.getModel();
            ConsoleSection e = model.getExecutingSection();
            if (e != null) {
                showHourglass(e.getEnd(), label);
                return;
            }
        } 
        removeProgressIndicator();
    }
    

    @Override
    protected void componentClosed() {
        removeProgressIndicator();
        super.componentClosed();
        pane = null;
        if (cloneableEditorSupport().getOpenedPanes() == null && session != null) {
            try {
                // terminate the JShell
                session.getEnv().shutdown();
            } catch (Exception ex) {
                Exceptions.printStackTrace(ex);
            }
        }
    }
    
    private JShellEnvironment env;
    
    private volatile boolean detached;
    
    private synchronized void detachFromSession() {
        session.getModel().removeConsoleListener(cl);
        cl = null;
        detached = true;
    }
    
    private void initialize() {
        assert SwingUtilities.isEventDispatchThread();
        JEditorPane pane = getEditorPane();
        if (pane == null) {
            return;
        }
        this.pane = pane;
        Document d = getEditorPane().getDocument();
        ShellSession s  = ShellSession.get(d);

        if (s == null || s == this.session) {
            // some default document, not interesting
            return;
        }
        if (s != this.session && this.session != null) {
            detachFromSession();
        }
        final JShellEnvironment env = ShellRegistry.get().getOwnerEnvironment(s.getConsoleFile());
        this.session = s;

        synchronized (this) {
            detached = false;
            cl = new CL();
            session.getModel().addConsoleListener(cl);
        }
        if (env != null && env != this.env) {
            this.env = env;
            env.addShellListener(cl);
        }
        
        // post in order to synchronize after initial shell startup
        session.post(() -> {
            SwingUtilities.invokeLater(this::initialResetCaret);
        });
        
        pane.setNavigationFilter(new NavFilter());
        
        d.addDocumentListener(new DocumentListener() {
            @Override
            public void insertUpdate(DocumentEvent e) {
                maybeDiscardEdits();
            }

            @Override
            public void removeUpdate(DocumentEvent e) {
                maybeDiscardEdits();
            }

            @Override
            public void changedUpdate(DocumentEvent e) {
                maybeDiscardEdits();
            }
        });
        d.putProperty(WrapperFactory.class, new WrapperFactory() {
            @Override
            public Trees wrapTrees(Trees trees) {
                return new CompletionFilter(trees);
            }
            
        });
        SwingUtilities.invokeLater(this::updateHourglass);
    }
    
    private void initialResetCaret() {
        resetEditableArea(true);
    }

    private void resetEditableArea(boolean caret) {
        if (pane == null || pane.getDocument() == null) {
            // probably closed
            return;
        }
        
        Document document = pane.getDocument();
        // may block/delay, check detached inside.
        document.render(() -> {
            if (detached) {
                return;
            }
            final LineDocument ld = LineDocumentUtils.as(document, LineDocument.class);
            if (ld == null) {
                return;
            }
            ConsoleModel model = session.getModel();
            ConsoleSection input = model.getInputSection();
            if (input != null) {
                int commandStart = input.getPartBegin();
    //            pane.setCaretPosition(commandStart);
                if (commandStart > 0 && commandStart < document.getLength()) {
                    return;
                }
                if (caret) {
                    pane.setCaretPosition(commandStart);
                }
            }
        });
    }
    
    /**
     * Modifies the movement of the caret ot strongly prefer movement within the
     * editable section.
     * If the caret is currently in non-editable section of the console, the filter does nothing
     * When in editable section, and the filter would escape it (either leave input section
     * entirely, or go to a prompt part of it), the filter will catch the caret and
     * <ul>
     * <li>if the caret is already at the first or last row of the input section, pass the navigation on.
     * <li>If the caret goes up or down, magicPosition is used to compute offset at the first/last
     * line of the input section; caret will go to that place.
     * </ul>
     * <ul>
     * <li>if the caret would enter the prompt but remain on the line (i.e. left), the caret will be moved at
     * the end of the preceding line, as if movement was made at the beginning of line.
     * <li>if the caret goes to the start of prompt (begin-line), nothing happens.
     * </ul>
     */
    private class NavFilter extends CascadingNavigationFilter {
        private JEditorPane lastPane;
        
        @Override
        public void moveDot(FilterBypass fb, int dot, Position.Bias bias) {
            if (handle(fb, dot, bias, true)) {
                super.moveDot(fb, dot, bias);
            }
        }
        
        private void setMoveDot(FilterBypass fb, int dot, Position.Bias bias, boolean setOrMove) {
            if (setOrMove) {
                super.moveDot(fb, dot, bias);
            } else {
                super.setDot(fb, dot, bias);
            }
        }
        
        private boolean handle(FilterBypass fb, int dot, Position.Bias bias, boolean setOrMove) {
            // sadly actions must set up a flag in order to be 'compatible':
            JEditorPane p = pane;
            if (p == null) {
                return true;
            }
            this.lastPane = p;
            if (p.getClientProperty("navigational.action") == null) {
                if (!(fb instanceof NavigationFilterBypass)) {
                    return true;
                }
                MoveCaretsOrigin orig = ((NavigationFilterBypass)fb).getOrigin();
                if (!MoveCaretsOrigin.DIRECT_NAVIGATION.equals(orig.getActionType())) {
                    return true;
                }
            }
            int current = fb.getCaret().getDot();
            ConsoleSection input = session.getModel().getInputSection();
            if (input == null) {
                return true;
            }
            int s = input.getStart();
            int e = input.getEnd();

            try {
                if (current >= s && current <= e) {
                    // move from within the area
                    if (dot >= s && dot <= e) {
                        return filterWithinSection(input, fb, dot, bias, setOrMove);
                    } else {
                        return filterOutOfSection(input, fb, dot, bias, setOrMove);
                    }
                }
                return true;
            } catch (BadLocationException ex) {
                Exceptions.printStackTrace(ex);
                // permit to navigate
                return true;
            }
        }
        
        /**
         * Handles movement within section. Essentialy just ensures that the caret remains after the prompts.
         * @param fb
         * @param dot
         * @param bias
         * @param setOrMove
         * @return B
         */
        private boolean filterWithinSection(ConsoleSection s, FilterBypass fb, int dot, Position.Bias bias, boolean setOrMove) {
            Caret c = fb.getCaret();
            int curPos = c.getDot();
            LineDocument ld = LineDocumentUtils.as(lastPane.getDocument(), LineDocument.class);
            if (ld == null) {
                return true;
            }
            int curLine =  LineDocumentUtils.getLineStart(ld, c.getDot());
            int dotLine =  LineDocumentUtils.getLineStart(ld, dot);
            // I want to avoid positioning into the prompts:
            for (Rng range : s.getPartRanges()) {
                if (range.start > dot) {
                    if (curLine == dotLine) {
                        // if the previous position was also in this range, just relax -
                        // positioned by e.g. mouse, let's do any movement necessary:
                        if (curPos == curLine) {
                            // exception, go to start
                            setMoveDot(fb, range.start, bias, setOrMove);
                            return false;
                        }
                        if (curPos < range.start) {
                            return true;
                        }
                    }
                    if (dot == dotLine) { 
                        // jump at the beginning of the line, assuming HOME
                        if (curPos == range.start) {
                            return true;
                        } else {
                            setMoveDot(fb, range.start, bias, setOrMove);
                            return false;
                        }
                    } else if (dotLine == curLine) {
                        // assuming LEFT, WORD-LEFT etc
                        setMoveDot(fb, Math.max(0, dot = dotLine - 1), bias, setOrMove);
                        return false;
                    }
                    setMoveDot(fb, range.start, bias, setOrMove);
                    return false;
                }
                if (range.end >= dot) {
                    break;
                }
            }
            return true;
        }

        private boolean filterOutOfSection(ConsoleSection s, FilterBypass fb, int dot, Position.Bias bias, boolean setOrMove) throws BadLocationException {
            Caret c = fb.getCaret();
            Point magPosition = c.getMagicCaretPosition();
            int curPos = c.getDot();
            if (magPosition == null) {
                // some other action that move-up / move-down, i.e. start of document / end document, navigation
                if (curPos == s.getPartBegin()|| curPos == s.getEnd()) {
                    return true;
                }
                int nDot;
                if (dot < s.getPartBegin()&& curPos != s.getStart()) {
                    nDot = s.getPartBegin();
                } else if (dot > s.getEnd() && curPos != s.getEnd()) {
                    nDot = s.getEnd();
                } else {
                    return true;
                }
                setMoveDot(fb, nDot, bias, setOrMove);
                return false;
            }
            
            // magicPosition is set, so the move is accross the lines (in Y axis)
            LineDocument ld = LineDocumentUtils.as(lastPane.getDocument(), LineDocument.class);
            if (ld == null) {
                return true;
            }
            int curLine =  LineDocumentUtils.getLineStart(ld, c.getDot());
            int ref;
            if (dot < s.getStart()) {
                // measure Y; get X from magicPosition.
                ref  = LineDocumentUtils.getLineStart(ld, s.getStart());
            } else if (dot >= s.getEnd()) {
                ref  = LineDocumentUtils.getLineStart(ld, s.getEnd());
            } else {
                return true;
            }
            if (curLine == ref) {
                return true;
            }
            Rectangle rect = lastPane.getUI().modelToView(lastPane, ref);
            rect.x = magPosition.x;
            int pos = lastPane.getUI().viewToModel(lastPane, rect.getLocation());
            if (pos < 0) {
                return true;
            }
            // I want to avoid positioning into the prompts:
            for (Rng range : s.getPartRanges()) {
                if (range.start > pos) {
                    pos = range.start;
                    break;
                }
                if (range.end >= pos) {
                    break;
                }
            }
            setMoveDot(fb, pos, bias, setOrMove);
            return false;
        }

        @Override
        public void setDot(FilterBypass fb, int dot, Position.Bias bias) {
            if (handle(fb, dot, bias, false)) {
                super.setDot(fb, dot, bias);
            }
        }
        
    }
    
    private void maybeDiscardEdits() {
        ShellStatus s = env.getStatus();
        ShellSession session = env.getSession();
        if (session == null || session.getModel() == null) {
            return;
        }
        if (session.getModel().isWritingResponse()) {
            OverrideEditorActions.flushUndoQueue(env.getConsoleDocument());   
        } else if (s == ShellStatus.INIT || s == ShellStatus.EXECUTE || s == ShellStatus.STARTING) {
            OverrideEditorActions.flushUndoQueue(env.getConsoleDocument());   
        }
    }
    
    private void updateStatusAndActivate() {
        updateHourglass();
        ShellStatus s = env.getStatus();
        if (s == ShellStatus.READY) {
            this.requestVisible();
            OverrideEditorActions.flushUndoQueue(env.getConsoleDocument());
        } else if (s == ShellStatus.EXECUTE) {
            OverrideEditorActions.flushUndoQueue(env.getConsoleDocument());
        }
    }
    
    private class CL implements ConsoleListener, Runnable, PropertyChangeListener, ShellListener {
        private boolean caret;
        private ShellSession saveSession;
        
        private CL() {
            this.saveSession = session;
        }

        @Override
        public void shellCreated(ShellEvent ev) {}

        @Override
        public void shellSettingsChanged(ShellEvent ev) {}
        
        @Override
        public void shellStatusChanged(ShellEvent ev) {
            SwingUtilities.invokeLater(ConsoleEditor.this::updateStatusAndActivate);
        }
        
        @Override
        public void shellStarted(ShellEvent ev) {
            if (session != env.getSession()) {
                SwingUtilities.invokeLater(ConsoleEditor.this::initialize);
            }
        }

        @Override
        public void shellShutdown(ShellEvent ev) {
            env.removeShellListener(this);
        }

        @Override
        public void propertyChange(PropertyChangeEvent evt) {
            if (evt.getSource() == session &&
                ShellSession.PROP_ACTIVE.equals(evt.getPropertyName())) {
                if (evt.getNewValue() != Boolean.TRUE) {
                    detachFromSession();
                }
            }
        }
        
        public void run() {
            if (detached || session != saveSession) {
                return;
            }
            resetEditableArea(caret);
        }
        
        @Override
        public void sectionCreated(ConsoleEvent e) {
            if (e.containsInput()) {
                caret = true;
                SwingUtilities.invokeLater(this);
            }
        }

        @Override
        public void sectionUpdated(ConsoleEvent e) {
            if (e.containsInput()) {
                SwingUtilities.invokeLater(this);
            }
        }

        @Override
        public void executing(ConsoleEvent e) {
            SwingUtilities.invokeLater(ConsoleEditor.this::updateHourglass);
        }
        
        public void closed(ConsoleEvent e) {
            detachFromSession();
        }
    }
    
    private ExecutingGlassPanel  executeWaitPanel;
    private JLabel  initializingPanel;
    
    private ExecutingGlassPanel prepareWaitPanel() {
        if (executeWaitPanel == null) {
            ExecutingGlassPanel p = new ExecutingGlassPanel();
            p.addStopListener(this::stopExecution);
            executeWaitPanel = p;
        }
        return executeWaitPanel;
    }
    
    private JComponent prepareInitPanel() {
        JLabel l = initializingPanel;
        if (initializingPanel == null) {
            l = new JLabel(Bundle.MSG_Initializing(), JLabel.LEADING);
            l.setIcon(new ImageIcon(
                getClass().getClassLoader().getResource(
                    "org/netbeans/modules/jshell/resources/wait16.gif"
                )
            ));
            initializingPanel = l;
        }
        return initializingPanel;
    }
    
    private void stopExecution(ActionEvent e) {
        Action a = pane.getActionMap().get(StopExecutionAction.NAME);
        a.actionPerformed(e);
        /*
        if (session != null) {
            BaseProgressUtils.runOffEventDispatchThread(session::stopExecutingCode, 
                    Bundle.LBL_AttemptingStop(), null, false, 100, 2000);
        }
                */
    }
    
    private JComponent progressIndicator;
    
    private JLayeredPane lastLayeredPane;
    
    private void removeProgressIndicator() {
        if (lastLayeredPane == null || progressIndicator == null) {
            return;
        }
        progressIndicator.setVisible(false);
        JLayeredPane lp = lastLayeredPane;
        lp.remove(progressIndicator);
        lp.repaint();
        progressIndicator = null;
        
    }
    
    private void showProgressIndicator(JComponent indicator, int position, String label) {
        removeProgressIndicator();
        try {
            if (pane == null) {
                return;
            }
            Rectangle r =  pane.getUI().modelToView(pane, position);
            JLayeredPane lp = JLayeredPane.getLayeredPaneAbove(pane);
            if (lp == null) {
                return;
            }
            if (r == null) {
                r = new Rectangle(); // 0:0
            }
            
            lp.add(indicator, JLayeredPane.POPUP_LAYER, 0);
            r.setSize(indicator.getPreferredSize());
            Rectangle converted = SwingUtilities.convertRectangle(
                    pane, r, lp);
            indicator.setBounds(converted);
            indicator.setVisible(true);
            this.lastLayeredPane = lp;
            this.progressIndicator = indicator;
        } catch (BadLocationException ex) {
            
        }

    }
    
    @NbBundle.Messages({
            "MSG_Evaluating=Evaluating, please wait...",
            "MSG_Initializing=Loading and initializing Java Shell. Please wait..."
    })
    private void showHourglass(int offset, String label) {
        ExecutingGlassPanel panel = prepareWaitPanel();
        panel.setMessage(label);
        showProgressIndicator(panel, offset + 1, label); // the character AFTER the termianting newline; should be on the next line.
    }

    @Override
    public int getPersistenceType() {
        return PERSISTENCE_NEVER;
    }
}
