import java.awt.*;

import javax.swing.*;
import javax.swing.event.*;
import javax.swing.text.*;

public class CheckerTest extends JPanel {
  private static final long serialVersionUID = 0;
  private static final boolean DEBUG_PRINTING = true;
  private static final String CHECK_COMPONENT = "foo";
  private static final SimpleAttributeSet UNDERLINE_ATTRIBUTE = new SimpleAttributeSet();
  private static final SimpleAttributeSet NO_UNDERLINE_ATTRIBUTE = new SimpleAttributeSet();
  private JTextPane editor = new JTextPane();
  
  // if last word has been checked (hack for efficiency in common case)
  private boolean lastWordChecked = true;
  
  static {
    StyleConstants.setUnderline(UNDERLINE_ATTRIBUTE, true);
    StyleConstants.setUnderline(NO_UNDERLINE_ATTRIBUTE, false);
    
    // TODO: The following looks like it'll allow alternative underlining:
    // http://forum.java.sun.com/thread.jspa?threadID=783137&messageID=4452563
  }
  
  public static void main(String[] args) {
    JFrame frame = new JFrame();
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.add(new CheckerTest());
    frame.pack();
    frame.setVisible(true);
  }
  
  public CheckerTest() {
    setLayout(new BorderLayout());
    setPreferredSize(new Dimension(400, 500));
    add(this.editor);
    
    // TODO: Check about single quotes for contractions...
    this.editor.getDocument().addDocumentListener(new DocumentListener() {
      public void insertUpdate(DocumentEvent event) {
        try {
          StyledDocument doc = (StyledDocument) event.getDocument();
          String text = doc.getText(0, doc.getLength());
          
          // ensures addition isn't initially underlined
          underlineRange(event.getOffset(), event.getLength(), false);
          
          if (event.getLength() == 1) {
            // first char of change
            char changeChar = text.charAt(event.getOffset());
            if (editor.getCaretPosition() == doc.getLength() - 1) {
              if (Character.isLetter(changeChar)) {
                // common case of adding letters to end
                lastWordChecked = false;
              } else {
                // if change is at the end of the document then we're only
                // concerned about last word
                // note: bounds configured to exclude newly added character
                int wordStart = getNonletterBound(text, text.length() - 2, true);
                String lastWord = text.substring(wordStart, text.length() - 1);
                if (checkWord(lastWord)) underlineRange(wordStart, lastWord.length(), true);
                lastWordChecked = true;
              }
            } else {
              if (Character.isLetter(changeChar)) {
                // change within word
                int wordStart = getNonletterBound(text, event.getOffset(), true);
                int wordEnd = getNonletterBound(text, event.getOffset(), false);
                String word = text.substring(wordStart, wordEnd + 1);
                underlineRange(wordStart, word.length(), checkWord(word));
              } else {
                // divided words - need to check both sides
                int firstWordStart = getNonletterBound(text, event.getOffset() - 1, true);
                int secondWordEnd = getNonletterBound(text, event.getOffset() + 1, false);
                String firstWord = text.substring(firstWordStart, event.getOffset());
                String secondWord = text.substring(event.getOffset() + 1, secondWordEnd + 1);
                underlineRange(firstWordStart, firstWord.length(), checkWord(firstWord));
                underlineRange(event.getOffset() + 1, secondWord.length(), checkWord(secondWord));
              }
            }
          } else {
            // pasting in a chunk of text (checks all words in modified range)
            int wordStart = getNonletterBound(text, event.getOffset(), true);
            while (wordStart < event.getOffset() + event.getLength()) {
              if (Character.isLetter(text.charAt(wordStart))) {
                int wordEnd = getNonletterBound(text, wordStart, false);
                System.out.println("range: " + wordStart + " - " + wordEnd);
                String word = text.substring(wordStart, wordEnd + 1);
                underlineRange(wordStart, word.length(), checkWord(word));
                wordStart = wordEnd + 1;
              } else {
                ++wordStart;
              }
            }
          }
        } catch (BadLocationException exc) {
          System.out.print("The program says neep! - " + exc);
        }
      }
      
      public void removeUpdate(DocumentEvent event) {
        try {
          StyledDocument doc = (StyledDocument) event.getDocument();
          String text = doc.getText(0, doc.getLength());
          int wordStart = getNonletterBound(text, event.getOffset() - 1, true);
          int wordEnd = getNonletterBound(text, event.getOffset(), false);
          String word = text.substring(wordStart, wordEnd + 1);
          underlineRange(wordStart, word.length(), checkWord(word));
        } catch (BadLocationException exc) {
          System.out.print("The program says neep! - " + exc);
        }
      }
      
      public void changedUpdate(DocumentEvent e) {}
    });
    
    this.editor.addCaretListener(new CaretListener() {
      public void caretUpdate(CaretEvent event) {
        if (!lastWordChecked && event.getDot() < editor.getDocument().getLength()) {
          try {
            // Cursor moving from end, so checking last word
            StyledDocument doc = editor.getStyledDocument();
            String text = doc.getText(0, doc.getLength());
            
            int wordStart = getNonletterBound(text, text.length() - 1, true);
            String word = text.substring(wordStart, text.length());
            underlineRange(wordStart, word.length(), checkWord(word));
            
            lastWordChecked = true;
          } catch (BadLocationException exc) {
            System.out.print("The program says neep! - " + exc);
          }
        }
      }
    });
  }
  
  /**
   * Provides index before the next non-letter in the string (ie, whitespace,
   * punctuation, digit, etc.). If not found then this provides the bounds of
   * the string.
   * @param phrase string to be checked
   * @param start index in which to begin search (inclusive)
   * @param before search is before index if true, after otherwise
   * @return index of next non-letter in the given direction, string bounds if
   *         not found
   */
  private int getNonletterBound(String phrase, int start, boolean before) {
    if (before) {
      for (int i = start; i >= 0; --i) {
        if (!Character.isLetter(phrase.charAt(i))) return i + 1;
      }
      
      return 0;
    } else {
      for (int i = start; i < phrase.length(); ++i) {
        if (!Character.isLetter(phrase.charAt(i))) return i - 1;
      }
      
      return phrase.length() - 1;
    }
  }
  
  /**
   * Determines if the word is valid or not. Currently this makes an arbitrary
   * check if it matches 'foo' but will be replaced with a call to the spell
   * checker API.
   * @param word word to be checked
   * @return true if valid, false otherwise
   */
  private static boolean checkWord(String word) {
    if (word.length() < 1) return false;
    if (DEBUG_PRINTING) System.out.println("Checking '" + word + "'");
    return word.contains(CHECK_COMPONENT);
  }
  
  /**
   * Sets a range in the editor to either be underlined or not.
   * @param start start of range to toggle underlining
   * @param length number of characters to toggle underlining
   * @param underline underlines range if true, clears underlining otherwise
   */
  private void underlineRange(final int start, final int length, final boolean underline) {
    if (length < 1) return;
    
    if (DEBUG_PRINTING) {
      String underlineDescription = underline ? "underlined" : "not underlined";
      System.out.println("Formatting " + start + " to " + (start + length) + " to be "
          + underlineDescription);
    }
    
    EventQueue.invokeLater(new Runnable() {
      public void run() {
        StyledDocument doc = CheckerTest.this.editor.getStyledDocument();
        AttributeSet attr = underline ? UNDERLINE_ATTRIBUTE : NO_UNDERLINE_ATTRIBUTE;
        doc.setCharacterAttributes(start, length, attr, false);
      }
    });
  }
}

