package jnyqide;

//
//  CodePane.java
//  nyquist
//
//  Created by Roger Dannenberg on 12/15/07.
//  Copyright 2007 Roger B. Dannenberg. All rights reserved.
//

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
import javax.swing.event.*;
import javax.swing.text.*;
import javax.swing.undo.*;

// CodePane subclasses a JScrollPane and initializes it to contain
// a JTextPane which holds a DefaultStyleDocument. Syntax coloring
// and paren balancing is performed.
//
// a CodePane also has a Timer -- it is used to blink a character somewhere.
//
public class CodePane extends JScrollPane implements DocumentListener,
        CaretListener, AdjustmentListener, KeyListener {
    public JTextPane pane;
    public DefaultStyledDocument doc;
    public Timer timer;
    public boolean evenParens; // used by MainFrame for command input
    // (when user types return, if parens are balanced, that is, evenParens
    // is true, then the command is sent to Nyquist)
    // when user types a close paren, the matching one is blinked, and
    // when user moves cursor, the matching paren is highlighted in bold
    public int blinkLoc; // where to blink a matching paren
    public int highlightLoc; // where to highlight a matching paren
    public boolean blinkOn;
    // mainFrame is a handle to access some methods, but it is also a
    // flag to tell us if this is a command entry window:
    public MainFrame mainFrame;
    public boolean isSal;
    int caretLine; // holds current line number
    int caretColumn; // holds current column
    int fontSize; // the font size
    JLabel statusBar;
    int indentedPosAfterNewline;
    boolean doUndo;
    
    Font mainFont;

    final int TIMER_DELAY = 1000; // ms

    public CodePane(Dimension preferredSize, MainFrame mf, 
                    JLabel sb, int fontSz) {
        super();
        blinkLoc = 0;
        blinkOn = false; // initialize
        fontSize = fontSz;
        mainFrame = mf;
        statusBar = sb;
        indentedPosAfterNewline = -1;
        doUndo = true; // normally actions are undoable, see hack below
        isSal = false;
        doc = new DefaultStyledDocument();
        pane = new JTextPane(doc);
        mainFont = new Font("sdfi", Font.PLAIN, 11);
        if (mainFont == null) {
            mainFont = new Font("Lucida Console", Font.PLAIN, 11);
        }
        pane.setFont(mainFont);
        
        getViewport().add(pane);
        setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);
        setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_ALWAYS);

        // create timer for blinking
        ActionListener blinkOffTask = new ActionListener() {
            public void actionPerformed(ActionEvent evt) {
                timer.stop(); // timer is just a one-shot, not periodic
                blinkOn = false;
                synchronousUpdate();
            }
        };
        timer = new Timer(TIMER_DELAY, blinkOffTask);

        doc.addDocumentListener(this);
        pane.addCaretListener(this);
        this.getHorizontalScrollBar().addAdjustmentListener(this);
        this.getVerticalScrollBar().addAdjustmentListener(this);

        final UndoManager undo = new UndoManager();
        doc.addUndoableEditListener(new UndoableEditListener() {
            public void undoableEditHappened(UndoableEditEvent evt) {
                // do not make style changes undoable -- these
                // are fixed automatically
                if (!evt.getEdit().getPresentationName().equals(
                        "style change") &&
                    doUndo) {
                    undo.addEdit(evt.getEdit());
                }
            }
        });

        // Create an undo action and add it to the text component
        pane.getActionMap().put("Undo", new AbstractAction("Undo") {
            public void actionPerformed(ActionEvent evt) {
                try {
                    if (undo.canUndo()) {
                        undo.undo();
                        forceReformatHack();
                    }
                } catch (CannotUndoException e) {
                }
            }
        });

        // Bind the undo action to ctl-Z
        pane.getInputMap().put(KeyStroke.getKeyStroke("control Z"), "Undo");
        // Bind the undo action to CMD-Z for Mac
        pane.getInputMap().put(KeyStroke.getKeyStroke(
                java.awt.event.KeyEvent.VK_Z,
                Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()), "Undo");

        // Create a redo action and add it to the text component
        pane.getActionMap().put("Redo", new AbstractAction("Redo") {
            public void actionPerformed(ActionEvent evt) {
                try {
                    if (undo.canRedo()) {
                        undo.redo();
                        forceReformatHack();
                    }
                } catch (CannotRedoException e) {
                }
            }
        });

        // Bind the redo action to ctl-Y
        pane.getInputMap().put(KeyStroke.getKeyStroke("control Y"), "Redo");
        // Bind the undo action to CMD-Y for Mac
        pane.getInputMap().put(KeyStroke.getKeyStroke(
                java.awt.event.KeyEvent.VK_Y,
                Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()), "Redo");

        // add self as a key listener
        pane.addKeyListener(this);

        if (preferredSize != null)
            setPreferredSize(preferredSize);

    }

    public void updateCaretLoc() {
        String text;
        try {
            text = pane.getText();
        } catch (Exception ex) {
            System.out.println("exception in updateCaretLoc");
            return;
        }
        int pos = pane.getCaretPosition();
        caretColumn = 0;
        caretLine = 0;
        int i = 0;
        while (i++ < pos) {
            if (text.charAt(i - 1) == '\n') {
                caretColumn = 0;
                caretLine++;
            } else {
                caretColumn++;
            }
        }
        // the first line is 1, not 0
        statusBar.setText(Integer.toString(caretLine + 1) + ":"
                + Integer.toString(caretColumn));
    }

    public void changedUpdate(DocumentEvent e) {
        // System.out.println("changedUpdate " + e);
    }

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

    public void insertUpdate(DocumentEvent e) {
        System.out.println("insertUpdate calls synchronousUpdate");
        synchronousUpdate();
    }

    public void caretUpdate(CaretEvent e) {
        // System.out.println("caretUpdate " + e.getDot() + " mark " +
        // e.getMark());
        if (e.getDot() == e.getMark()) {
            highlightMatchingParen(e.getDot() - 1);
            updateCaretLoc();
            // System.out.println("caretUpdate: " + e.getDot() + " is line " +
            // caretLine + " col " + caretColumn);
        } else {
            highlightMatchingParen(-1);
        }
    }

    public void adjustmentValueChanged(AdjustmentEvent evt) {
        // System.out.println("adjustmentValueChanged");
        synchronousUpdate();
    }

    // This method can be invoked from any thread. It
    // invokes the setText and modelToView methods, which
    // must run in the event dispatching thread. We use
    // invokeLater to schedule the code for execution
    // in the event dispatching thread.
    protected void highlightMatchingParen(final int pos) {
        SwingUtilities.invokeLater(new Runnable() {
            public void run() {
                if (pos == -1) {
                    highlightLoc = pos;
                } else if (charAtPos(pos) == ')') {
                    try {
                        highlightLoc = findOpenParen(pane.getText(0, pos), pos);
                    } catch (BadLocationException e) {
                        System.out.println(e);
                        return;
                    }
                    if (highlightLoc == pos)
                        highlightLoc = -1;
                } else { // not at close paren, so turn off highlight
                    highlightLoc = -1;
                }
                synchronousUpdate();
            }
        });
    }

    char charAtPos(int pos) {
        String text;
        try {
            text = doc.getText(pos, 1);
        } catch (BadLocationException e) {
            return 0;
        }
        return text.charAt(0);
    }

    // complete the KeyListener interface:
    public void keyReleased(KeyEvent ke) {
        return;
    }

    public void keyPressed(KeyEvent ke) {
        return;
    }

    public void keyTyped(KeyEvent ke) {
        char ch = ke.getKeyChar();
        System.out.println("CodePane keyTyped " +
                ((ch >= 32 && ch <= 126) ? "|" + ch + "|" : (int) ch));
        /*
        try {
            System.out.println("BEGIN_CONTENT:\n" + 
                    doc.getText(0, doc.getLength()) + "END-CONTENT\n");
        } catch (BadLocationException e) {
            System.out.println(e);
        }
        */
        // cases:
        // newline
        // mainFrame
        // evenParens: sendCommandToNyquist
        // !evenParens:
        // LISP: print "paren mismatch"
        // indent handling
        // else
        // indent handling
        // otherwise
        // process key
        //
        // this is where we put auto parentheses:
        int pos = pane.getCaretPosition();
        System.out.println("keyTyped caret position: " + pos);

        // backspace can take out indentation immediately after newline that
        // generates indentation, but not otherwise, so clear
        // indentedPosAfterNewline as soon as something is typed (other than
        // backspace):
        if (ch != '\b') {
            indentedPosAfterNewline = -1;
        }
        if (ch == '\n') {
            // insertIndentation(p);
            if (mainFrame != null) { // if this is the command entry window
                if (evenParens) {
                    if (doc.getLength() == pos) {
                        // only do indentation if newline is at end of text
                        insertIndentation(pos);
                        // remove newline
                        pos = doc.getLength(); // take out newline at end
                        int newlinePos = pos - 1;
                        try {
                            while (!doc.getText(newlinePos, 1).equals("\n")) {
                                newlinePos--;
                            }
                        } catch (BadLocationException e) {
                            System.out.println(e);
                            return;
                        }
                        pane.setSelectionStart(newlinePos);
                    } else { // no indentation, just remove newline
                        pane.setSelectionStart(pos - 1);
                    }
                    pane.setSelectionEnd(pos);
                    pane.replaceSelection("");
                    mainFrame.sendCommandToNyquist();
                } else {
                    if (!isSal) {
                        mainFrame.jOutputArea
                                .append("Invalid command - paren mismatch\n");
                    }
                    insertIndentation(pos);
                }
            } else { // not the command entry window
                if (insertIndentation(pos) > 1) {
                    indentedPosAfterNewline = pane.getCaretPosition();
                    // the indentation will be removed at once
                } // otherwise a backspace will either delete one space or one newline
                // so we do not want indentedPosAfterNewline set to enable deleting
                // more spaces
            }
        } else if (ch == '\t') {
            int spaces = TextColor.INDENT - (caretColumn - 1)
                    % TextColor.INDENT;
            // remove tab and replace with spaces
            pane.setSelectionStart(pos - 1);
            pane.setSelectionEnd(pos);
            pane.replaceSelection("        ".substring(0, spaces));
        } else if (ch == '\b' && 
                   indentedPosAfterNewline - 1 == pane.getCaretPosition()) {
            // the user's backspace put us one character back from where we
            // indented multiple spaces -- delete them all
            indentedPosAfterNewline = -1; // don't do this until another newline
            // clear all the way back to beginning of line in this case
            int newlinePos = pos - 1;
            try {
                while ((newlinePos >= 0) && !doc.getText(newlinePos, 1).equals("\n")) {
                    newlinePos -= 1;
                }
                // now newlinePos is the position of the typed newline. Delete from
                // after newline to current position
                pane.setSelectionStart(newlinePos + 1);
                pane.setSelectionEnd(pos);
                pane.replaceSelection("");
            } catch (BadLocationException e) {
                System.out.println(e);
                return;
            }
        } else { // ordinary key handling
            if (ch == ')') {
                // get caret location
                blinkParen(pos);
            } else if (ch == '(') {
                if (MainFrame.prefParenAutoInsert) {
                    pane.replaceSelection(")");
                    pane.setCaretPosition(pos);
                }
            } else {  // typing ordinary character replaces selection if any
                /* System.out.println("Ord character " + (int) ch + 
                        " replaces selection |" + pane.getSelectedText() + "|");
                */
                pane.replaceSelection("");
            }
            String text;
            int start = Math.max(pos - 100, 0);
            try {
                text = pane.getText(start, pos - start);
            } catch (Exception ex) {
                System.out.println("exception in keyTyped: start " + start
                        + " pos " + pos + " pane " + pane);
                return;
            }
            // simulate typing the character unless it's a backspace
            if (ch != '\b') {
                text += ch;
                /*
                System.out.println("with new char, text " + text + "\n");
                */
            } else {
                pos--;
                System.out.println("normal backspace, pos " + pos);
            }
            String identifier;
            int identLoc;
            boolean forceExact = false; // force an exact match to complete word
            if (isSal) { // generate completion list for SAL
                // delimiters are "{}(),[]\n #\""
                // look back for identifier immediately before pos
                // if none found, look back for unbalanced paren, then search
                // back from there
                identifier = getSalIdentifier(text, pos - start);
                int len = identifier.length();
                identLoc = pos - len + 1;
                forceExact = (len > 0 && identifier.charAt(len - 1) == ' ');
                if (len == 0) { // not found
                    int openParenLoc = findOpenParen(text, pos - start);
                    identifier = getSalIdentifier(text, openParenLoc);
                    identLoc = start + openParenLoc - identifier.length();
                    forceExact = true;
                }
            } else { // generate completion list for Lisp
                // look back for unmatched open paren, then forward for
                // identifier
                // look back a maximum of 100 characters
                int openParenLoc = findOpenParen(text, pos - start);
                openParenLoc++;
                identifier = getIdentifier(text, openParenLoc);
                identLoc = start + openParenLoc;
                int len = identifier.length();
                forceExact = (len > 0 && identifier.charAt(len - 1) == ' ');
            }
            // put up words list
            WordList.printList(identifier, pane, identLoc, pos + 1, forceExact,
                    isSal);
        }
        forceReformatHack();
    }

    // apparently, a bug was introduced into Swing where line-wrap markers
    // are not updated when text is inserted. To work around this problem,
    // this method clears out the whole document, puts it back, and runs the
    // syntax coloring again. That should do it. There's probably a faster
    // simpler cleaner way, e.g. these edits have to be excluded from undo 
    // using another hack.
    public void forceReformatHack() {
        try {
            doUndo = false;  // disable making these events undoable
            int pos = pane.getCaretPosition();  // save it to restore below
            int docLen = doc.getLength();
            String docText = doc.getText(0, docLen);
            /* 
            System.out.println("replace all hack: docText " + docText + "\n");
            */
            pane.setSelectionStart(0);
            pane.setSelectionEnd(docLen);
            pane.replaceSelection(docText);
            pane.setCaretPosition(pos);
            doUndo = true;
        } catch (Exception ex) {
            System.out.println("exception getting docText" + ex);
        }
    }


    // get an identifier starting at pos -- if a complete identifier (terminated
    // by something)
    // is found, the identifier is terminated by a space character
    static String getIdentifier(String text, int pos) {
        int idEnd = pos; // search forward to find identifier
        String lispIdChars = "~!@$%^&*-_+={}|:<>?/";
        if (text.length() == 0 || pos < 0)
            return text; // special cases
        while (true) {
            if (idEnd == text.length()) {
                text = text.substring(pos); // still typing
                break;
            }
            char idChar = text.charAt(idEnd);
            if (!Character.isLetterOrDigit(idChar)
                    && lispIdChars.indexOf(idChar) == -1) {
                text = text.substring(pos, idEnd) + " "; // complete
                break; // idEnd is one past last character
            }
            idEnd++;
        }
        return text;
    }

    static int findColumnOf(String text, int pos) {
        int col = 0;
        pos--;
        while (pos >= 0 && text.charAt(pos) != '\n') {
            col++;
            pos--;
        }
        return col;
    }

    // returns how many spaces we indented
    private int insertIndentation(int p) {
        String text;
        int desired = 0; // desired indentation of the previous line
        // initialized because compiler can't figure out that it's
        // initialized below before it is used
        try {
            text = pane.getText(0, p);
        } catch (Exception e) {
            System.out.println("exception in insertIndentation");
            return 0;
        }
        int indent;
        if (isSal) {
            indent = salIndentAmount(p);
            desired = TextColor.indentBefore;
        } else {
            indent = autoIndentAmount(text, p);
        }
        String indentation = "";
        for (int i = 0; i < indent; i++) {
            indentation += " ";
        }
        // System.out.println("before replaceSelection(indentation)");
        pane.replaceSelection(indentation);
        // System.out.println("after replaceSelection(indentation)");
        if (isSal) { // indent the previous line as well
            // first find the beginning of the previous line
            int prevStart = p - 1; // index of newline
            // System.out.println("prevStart " + prevStart + " char |" +
            // text.charAt(prevStart) + "|");
            assert (text.charAt(prevStart) == '\n');
            while (prevStart - 1 >= 0 && text.charAt(prevStart - 1) != '\n')
                prevStart--;
            // System.out.println("PREV LINE BEGIN " + prevStart + " in |" +
            // text + "|");
            // find the actual indentation of the previous line
            int prevIndent = 0; // search forward from prevStart for nonspace
            while (text.charAt(prevStart + prevIndent) == ' '
                    || text.charAt(prevStart + prevIndent) == '\t')
                prevIndent++;
            // System.out.println("PREV INDENT " + prevIndent +
            // " DESIRED " + desired);
            // adjust the indentation
            int delta = desired - prevIndent;
            p = pane.getSelectionStart() + delta;
            if (delta > 0) {
                indentation = "";
                while (delta > 0) {
                    indentation += " ";
                    delta--;
                }
                // System.out.println("INSERT " + delta +
                // " SPACES AT " + prevStart);
                pane.setSelectionStart(prevStart);
                pane.setSelectionEnd(prevStart);
                pane.replaceSelection(indentation);
            } else if (delta < 0) {
                // System.out.println("BACKSPACE " + -delta +
                // " AT " + prevStart);
                pane.setSelectionStart(prevStart);
                pane.setSelectionEnd(prevStart - delta);
                pane.replaceSelection("");
            }
            // System.out.println("MOVE CARET TO " + p);
            pane.setSelectionStart(p);
            pane.setSelectionEnd(p);
        }
        return indent;
    }

    private int salIndentAmount(int p) {
        // how much is the default indentation?
        // p is position AFTER a newline, so back up one to get
        // the index of the newline character
        // System.out.println("salIndentAmount " + p);
        TextColor.format(this, p - 1);
        // System.out.println("salIndent return " + TextColor.indentAfter);
        return TextColor.indentAfter;
    }

    // find auto-indent position:
    // first, go back and find open paren that would match a close paren
    // second search forward to find identifier
    // if identifier is defun, defmacro, let, let*, or prog, etc.
    // indent to paren posn + 2
    // else indent to the first thing after the identifier
    int autoIndentAmount(String text, int pos) {
        int openParenLoc = findOpenParen(text, pos);
        // System.out.println("autoIndent: openParenLoc = " + openParenLoc);
        if (openParenLoc == -1)
            return 0;
        String ident = getIdentifier(text, openParenLoc + 1);
        if (ident.equals("defun ") || ident.equals("defmacro ")
                || ident.equals("let ") || ident.equals("let* ")
                || ident.equals("dotimes ") || ident.equals("dolist ")
                || ident.equals("simrep ") || ident.equals("seqrep ")
                || ident.equals("prog ") || ident.equals("prog* ")
                || ident.equals("progv ")) {
            pos = openParenLoc + 2;
        } else {
            pos = openParenLoc + ident.length();
            System.out.println("auto-indent, pos " + pos + ", ident " + ident
                    + ", length " + ident.length());
            while (pos < text.length()
                    && Character.isWhitespace(text.charAt(pos))) {
                if (text.charAt(pos) == '\n') {
                    // if the end of the line looks like "(foo \n" then the tab
                    // position
                    // will be indented two from the open paren (ignore the
                    // identifier):
                    pos = openParenLoc + 2;
                    break;
                }
                pos++;
            }
            // System.out.println("pos " + pos);
        }
        return findColumnOf(text, pos);
    }

    public static boolean inComment(String text, int pos)
    // search back to newline for ";" indicating comment
    // assumes text[pos] is not escaped or in string
    {
        boolean inString = false;
        while (pos > 0) {
            char c = text.charAt(pos);
            if (c == ';')
                return true;
            if (c == '\n')
                return false;
            pos = backup(text, pos, false);
        }
        return false;
    }

    static String SalIdChars = "{}(),[]\n #\"";

    public static String getSalIdentifier(String docText, int pos) {
        // System.out.println("|" + docText + "| " + pos);
        int start = pos;
        if (pos < 0)
            return ""; // special case: no place to search from
        // allow back up over single open paren
        if (docText.charAt(pos) == '(')
            start = start - 1;
        while (start >= 0 && SalIdChars.indexOf(docText.charAt(start)) == -1) {
            start--;
        }
        // protect from bogus arguments
        if (start < -1 || pos >= docText.length())
            return "";
        // if id is terminated by open paren, substitute blank so that
        // when we search lisp-syntax wordlist we get a more precise match.
        // E.g. "osc(" becomes "osc " which will not match "osc-enable ..."
        if (docText.charAt(pos) == '(')
            return docText.substring(start + 1, pos) + " ";
        else
            return docText.substring(start + 1, pos + 1);

    }

    public static int findOpenParen(String docText, int pos) {
        int findx = -1;
        try {
            boolean inString = false; // need to get it from text color
            findx = backup(docText, pos, inString);
            if (findx == pos - 1 && findx > 0 && docText.charAt(findx) == '\\') {
                // escaped paren
                return pos;
            } else if (inComment(docText, findx)) { // in comment
                return pos;
            }
            // at this point we know there is a closed paren.
            // go back until you find the matching open paren.
            int closed = 1;
            while (findx >= 0 && closed > 0) {
                char c = docText.charAt(findx);
                if (c == '(' || c == ')') {
                    if (!inComment(docText, findx)) {
                        if (c == '(')
                            closed--;
                        else if (c == ')')
                            closed++;
                    }
                }
                if (closed > 0) // not done, back up
                    findx = backup(docText, findx, false);
            }
        } catch (Exception e) {
            // System.out.println("findOpenParen " + e);
        }
        // System.out.println("findOpenParen returns " + findx);
        return findx;
    }

    private static int backup(String text, int pos, boolean inString)
    // find an index in text before pos by skipping over strings
    // and escaped characters of the form #\A, but do not consider
    // comment lines. If findx is zero, return result is -1.
    {
        int findx = pos - 1;
        while (true) {
            if (findx < 0) {
                return findx;
            }
            char c = text.charAt(findx);
            if (inString) {
                if (c == '"') {
                    // could it be escaped?
                    if (findx > 0) {
                        char pre = text.charAt(findx - 1);
                        if (pre == '"')
                            findx--; // escaped as ""
                        else if (pre == '\\')
                            findx--; // escaped as \"
                        else
                            inString = false;
                    } else
                        inString = false;
                } // else keep searching for string closing
            } else { // not inString
                // test for escaped character
                if (findx > 0) {
                    char pre = text.charAt(findx - 1);
                    if (pre == '\\')
                        findx--; // escaped
                    else
                        inString = (c == '"');
                } // else if c == '"' then ...
                    // internal error: text begins with quote, but
                    // inString is false. Just ignore it because it
                    // may be that the coloring hasn't run yet
                    // and all will become well.
            }
            if (!inString || findx <= 0) {
                return findx;
            }
            findx--;
        }
    }

    void synchronousUpdate() {
        // System.out.println("synchronousUpdate called");
        final CodePane codePane = this;
        final JViewport v = getViewport();
        final Point pt = v.getViewPosition();
        final Dimension e = v.getExtentSize();
        EventQueue.invokeLater(new Runnable() {
            public void run() {
                // System.out.println("calling TextColor");
                codePane.evenParens = TextColor.format(codePane, 0);
                // System.out.println("returned from TextColor");
                codePane.updateFontSize(fontSize);
            }
        });
    }

    void blinkParen(int pos) {
        try {
            String docText = doc.getText(0, pos);
            int openParenLoc = findOpenParen(docText, pos);
            if (openParenLoc >= 0 && openParenLoc < pos) {
                blink(openParenLoc);
            }
        } catch (Exception e) {
            System.out.println(e);
        }
    }

    // the blink interface: call blink(loc) to make a character blink
    void blink(int loc) {
        timer.start();
        blinkOn = true;
        blinkLoc = loc;
    }

    public void updateFontSize(int size) {
        final MutableAttributeSet attributeSet = new SimpleAttributeSet();
        StyleConstants.setFontSize(attributeSet, size);
        doc.setCharacterAttributes(0, 1000000, attributeSet, false);
    }

    public void setFontSize(int size) {
        fontSize = size;
        updateFontSize(size);
    }

}
