/*
 * @(#)Tester.java	0.1 2001/08/27
 *
 * Copyright 2001 by Carnegie Mellon, All rights reserved.
 */

package ksdb;
import java.awt.datatransfer.*;
import java.awt.event.*;
import java.io.IOException;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.*;
import javax.swing.*;
import javax.swing.tree.*;

import edu.cmu.lti.kantoo.analyzer.*;
import edu.cmu.lti.kantoo.network.*;
import edu.cmu.lti.kantoo.shared.*;


/**
 * Tester lets the user run the Analyzer & Generator on the KSDB
 * NOTE: Does nothing to manage sentence locks. This module should only
 * be called after all sentences involved have been read-locked by the user.
 *
 * @author  David Svoboda
 * @version 0.1 2001/08/27
 */
public class Tester extends JPanel {

  /**
   * Properties for the Tester object
   */
  private PropertyManager properties = new PropertyManager(Tester.class);

  /**
   * @return properties
   */
  public PropertyManager getProperties() {return properties;}


  /**
   * The database used by this Tester
   */
  private final Ksdb ksdb;


  /**
   * the set of commands we support
   */
  private ActionMap commands;

  /**
   * @return commands map
   */
  public ActionMap getCommands() {return commands;}


  /**
   * Top-level frame this window belongs to
   */
  private JFrame frame;

  /**
   * @return Top-level frame this window belongs to
   */
  public JFrame getFrame() {return frame;}

  /**
   * KSDBStartup associated with this tester
   */
  private KSDBStartup startup = null;

  /**
   * Sets the startup, used upon exit
   * @param KSDBStartup startup to close upon exit
   */
  void setKSDBStartup(KSDBStartup startup) {this.startup = startup;}


  /**
   * Tree representing the data
   */
  JTree jTree;

  /**
   * The sentences being managed
   */
  private ResultSet sentences;


  private JProgressBar progress;


  /// we have to move these somewhere else!!!
  private int currentAnalyzerVersion = 0;
  private int currentSourceLanguage = 0;
  private String sourceLanguageName = null;
  private int correctAnalyzerVersion = 0;
  private int currentGeneratorVersion = 0;
  private int currentTargetLanguage = 0;
  private String targetLanguageName = null;
  private int correctGeneratorVersion = 0;

  int baseAnalyzerVersion = 0;
  int baseGeneratorVersion = 0;

  private void initServers() {
    // Connect to Analyzer
    KantooConnectionWorker analyzer = ksdb.getKantooConnectionWorker( true);
    currentSourceLanguage = ksdb.getLanguage( true, analyzer);
    sourceLanguageName = ksdb.selectString("name FROM Source_Language WHERE id=" + currentSourceLanguage);
    currentAnalyzerVersion = ksdb.getCurrentVersionID( true, analyzer, currentSourceLanguage);
    baseAnalyzerVersion = currentAnalyzerVersion;
    correctAnalyzerVersion = ksdb.getCorrectVersionID( true, currentSourceLanguage);

    // Connect to Generator
    KantooConnectionWorker generator = ksdb.getKantooConnectionWorker(false);
    currentTargetLanguage = ksdb.getLanguage( false, generator);
    targetLanguageName = ksdb.selectString("name FROM Target_Language WHERE id=" + currentTargetLanguage);
    currentGeneratorVersion = ksdb.getCurrentVersionID( false, generator, currentTargetLanguage);
    baseGeneratorVersion = currentGeneratorVersion;
    correctGeneratorVersion = ksdb.getCorrectVersionID( false, currentTargetLanguage);
  }

  /**
   * Any thread that builds a Tester can wait on this object; it will get
   * notified when initialization is completed (by a worker thread)
   */
  static public Object InitLock = new Object();

  /**
   * Constructs a tester
   * @param db        KSDB connection
   * @param document  Document being edited (0 means query)
   * @param query     Query to be sent. (NULL means document)
   * @throws IOException  if error occurs accessing the KANTOO servers
   */
  public Tester(Ksdb db, int document, String query) throws IOException {
    this.ksdb = db;
    this.commands = PropertyActionMap.createActionMap(getActions());    

    // Build the tree
    this.jTree = new JTree();
    jTree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
    DefaultTreeCellRenderer dtcr = new DefaultTreeCellRenderer();
    dtcr.setBackgroundSelectionColor( java.awt.Color.decode( getProperties().get("background.selection.color")));
    jTree.setCellRenderer( dtcr);

    // Try scoring any node that is double-clicked on
    jTree.addMouseListener(new MouseAdapter() {
	public void mousePressed(MouseEvent e) {
	  if (jTree.getRowForLocation( e.getX(), e.getY()) != -1 &&
	      e.getClickCount() == 2)
	    tryScoring( jTree.getPathForLocation(e.getX(), e.getY()));
	}});
    JScrollPane treeView = new JScrollPane(jTree);
    this.setLayout(new java.awt.BorderLayout());
    this.add("Center", treeView);
    this.progress = new JProgressBar();
    this.add("South", progress);
    this.sentences = ksdb.select((query != null) ? query :
				 "Sentence.id, Sentence.text,Sentence.previous"
				 + " FROM Sentence, Context"
				 + " WHERE Sentence.id=Context.sentence"
				 + " AND Context.document=" + document
				 + " ORDER BY Context.position");
    SQLConnection.last( sentences);
    final int sentenceCount = SQLConnection.getRow( sentences) + 1;

    Thread worker = new Thread() {
	public void run() {
	  synchronized (InitLock) {
	    progress.setMinimum(-2);
	    progress.setMaximum( sentenceCount * 2);
	    progress.setStringPainted(true);

	    progress.setString("Connecting to Servers");
	    progress.setValue(-1);
	    initServers();

	    // Build tree. (Get the model later because that is slow)
	    progress.setString("Building Tree");
	    progress.setValue(0);
	    TreeModel tm =new DefaultTreeModel(new AllSentencesTreeNode(),false);
	    jTree.setModel( tm);
	    jTree.setSelectionRow( 0);
	    progress.setStringPainted( false);
	    InitLock.notify();
	  }
	}
      };
    worker.start();
  }

  /**
   * Constructs a tester
   * @param title     Title of window
   * @param db        KSDB connection
   * @param document  Document being edited (0 means query)
   * @param query     Query to be sent. (NULL means document)
   * @return          top-level frame
   */
  public static Tester createFrame(String title, Ksdb ksdb, int document,
				   String query) throws IOException {
    final Tester tester = new Tester( ksdb, document, query);
    tester.setEnabled( false);

    tester.frame = new JFrame();
    tester.frame.setTitle( title);

    JMenuBar mb = new JMenuBar();
    new PropertyActionMap( tester.properties, "menubar",
			   tester.getCommands()).addtoMenubar( mb);
    JToolBar tb = new JToolBar();
    new PropertyActionMap( tester.properties, "toolbar",
			   tester.getCommands()).addtoToolbar( tb);

    JPanel panel = new JPanel();
    panel.setLayout(new java.awt.BorderLayout());
    panel.add("North", tb);
    panel.add("Center", tester);
    tester.frame.getContentPane().add("North", mb);
    tester.frame.getContentPane().add("Center", panel);
    tester.frame.pack();
    tester.jTree.requestFocus();
    new PropertyActor( tester.properties, "Tester").loadDimensions( tester.frame);

    tester.frame.addWindowListener(new WindowAdapter() {
	public void windowClosing(WindowEvent w) {tester.close();}});
    return tester;
  }


  /**
   * Closes the window, if user wishes it. May also exit system
   * @param cancel If true, user may cancel the exit
   */
  public void close() {
    properties.store();
    frame.setVisible( false);
    if (startup != null) startup.close( this);
    else System.exit(0);
  }


  /// Actions

  /**
   * Indicates that node's data has changed and should be reset
   * @param node Node to be changed
   */
  public void resetTreeNode(SentenceTreeNode node) {
    TreePath treePath = new TreePath(new Object[] { jTree.getModel().getRoot(),
						    node});
    node.reset();
    ((DefaultTreeModel) jTree.getModel()).reload( node);
    jTree.setSelectionPath( treePath);
    jTree.collapsePath( treePath);
  }


  class ExitAction extends AbstractAction {
    ExitAction() {super( "exit");}
    public void actionPerformed(ActionEvent e) {close();}
  }


  // Construct a JComboBox consisting of the results of a selection
  static private JComboBox makeComboBox(ResultSet rs) {
    JComboBox box = new JComboBox();
    while (SQLConnection.next(rs)) box.addItem( SQLConnection.getString(rs,1));
    return box;
  }

  class BaseAction extends AbstractAction {
    BaseAction() {super( "base");}
    public void actionPerformed(ActionEvent e) {
      final ResultSet analyzerSelection
	= ksdb.select("name FROM Analyzer_Version"
		      + " WHERE name<>" + SQLConnection.quote("CORRECT")
		      + " AND language=" + currentSourceLanguage
		      + " ORDER BY timestamp DESC");
      final JComboBox analyzer = makeComboBox( analyzerSelection);
      final ResultSet generatorSelection
	= ksdb.select("name FROM Generator_Version"
		      + " WHERE name<>" + SQLConnection.quote("CORRECT")
		      + " AND language=" + currentTargetLanguage
		      + " ORDER BY timestamp DESC");
      final JComboBox generator = makeComboBox( generatorSelection);

      final JOptionPane pane
	= new JOptionPane( new Object[] { "Select baseline analyzer and generator versions:", analyzer, generator }, JOptionPane.QUESTION_MESSAGE, JOptionPane.OK_CANCEL_OPTION);
      pane.createDialog( Tester.this, "Base Versions").show();

      Object selectedValue = pane.getValue();
      if (selectedValue == null)
	return; // user closed dialog
      if (((Integer) selectedValue).intValue() == JOptionPane.CANCEL_OPTION)
	return; // user cancelled dialog

      // User changed versions
      baseAnalyzerVersion = ksdb.selectInt("id FROM Analyzer_Version WHERE name=" + SQLConnection.quote( analyzer.getSelectedItem().toString()));
      baseGeneratorVersion = ksdb.selectInt("id FROM Generator_Version WHERE name=" + SQLConnection.quote( generator.getSelectedItem().toString()));
    }
  }

  /**
   * Attempts to find a scorable node close to node
   * @param path Path of node that was selected or clicked on. May be null
   */
  private void tryScoring(TreePath path) {
    try {
      TreeNode node = (TreeNode) path.getLastPathComponent();
      ScorableTreeNode selected = (node instanceof ScorableTreeNode)
	? (ScorableTreeNode) node 
	: (node.getParent() instanceof ScorableTreeNode)
	? (ScorableTreeNode) node.getParent() : null;
      selected.score();
    } catch (NullPointerException x) {/* ignore */}
  }

  /**
   * Attempts to find a scorable node close to node
   * @param path Path of node that was selected or clicked on. May be null
   */
  private void tryCommenting(TreePath path) {
    try {
      TreeNode node = (TreeNode) path.getLastPathComponent();
      ScorableTreeNode selected = (node instanceof ScorableTreeNode)
	? (ScorableTreeNode) node 
	: (node.getParent() instanceof ScorableTreeNode)
	? (ScorableTreeNode) node.getParent() : null;
      selected.comment();
    } catch (NullPointerException x) {/* ignore */}
  }


  static JTextField selectionField = new JTextField();

  class CopyAction extends AbstractAction {
    CopyAction() {super( "copy-to-clipboard");}
    public void actionPerformed(ActionEvent e) {
      try {
	final String selection = ((TesterTreeNode) (jTree.getSelectionPath().getLastPathComponent())).getText();
	Thread worker = new Thread() {
	  public void run() {
	    selectionField.setText( selection);
	    selectionField.selectAll();
	    selectionField.copy();
	  }};
	worker.start();
      } catch (Exception x) {/* ignore */}
    }
  }

  class ScoreAction extends AbstractAction {
    ScoreAction() {super( "rate");}
    public void actionPerformed(ActionEvent e) {
      tryScoring( jTree.getSelectionPath());
    }
  }

  class CommentAction extends AbstractAction {
    CommentAction() {super( "comment");}
    public void actionPerformed(ActionEvent e) {
      tryCommenting( jTree.getSelectionPath());
    }
  }

  abstract class KantooAction extends AbstractAction {
    KantooAction(String title) {super( title);}

    abstract protected void runKantoo(ProgressMonitor monitor) throws Exception;

    public void actionPerformed(ActionEvent e) {
      int size = ((TreeNode) (jTree.getModel().getRoot())).getChildCount();
      final ProgressMonitor monitor = new ProgressMonitor( Tester.this, new Object[] { "Running KANTOO on " + size + " sentences:" }, null, 0, size);
      Thread worker = new Thread() {
	  public void run() {
	    try {runKantoo( monitor);
	    } catch (Exception x) {ReportError.warning(x);}
	    for (Enumeration e = ((TreeNode) (jTree.getModel()).getRoot()).children(); e.hasMoreElements();)
	      resetTreeNode((SentenceTreeNode) e.nextElement());
	    monitor.close();
	  }};
      worker.start();
    }
  }

  class AnalyzeAction extends KantooAction {
    AnalyzeAction() {super("analyze");}

    protected void runKantoo(ProgressMonitor monitor) throws Exception {
      ksdb.analyzeSentences(sentences, monitor);
      jTree.requestFocus();
    }
  }

  class GenerateAction extends KantooAction {
    GenerateAction() {super("generate");}

    protected void runKantoo(ProgressMonitor monitor) throws Exception {
      ksdb.translateSentences( sentences, 0, monitor);
      jTree.requestFocus();
    }
  }



  class NextAction extends AbstractAction {
    String tag;
    NextAction(String title, String tag) {
      super( title);
      this.tag = tag;
    }

    public void actionPerformed(ActionEvent e) {
      Thread worker = new Thread() {
	  public void run() {
	    // First determine index of sentence to start with.
	    // either one that selection is in, or first one
	    TreeNode root = (TreeNode) (jTree.getModel().getRoot());
	    int current = -1;
	    int limit = root.getChildCount();
	    try {
	      TreePath treePath = jTree.getSelectionPath();
	      current = root.getIndex((TreeNode) (treePath.getPathComponent(1)));
	    } catch (Exception x) {/* ignore */}

	    // Instantiate translations until appropriate one arises.
	    progress.setMinimum( 0);
	    progress.setMaximum( limit);
	    progress.setString("Finding next sentence");
	    progress.setStringPainted(true);
	    TreeNode currentNode;
	    for (current++; current < limit; current++) {
	      progress.setValue( current);
	      currentNode = root.getChildAt(current);
	      TreeNode translation = (currentNode.getChildAt(0).getChildAt(0));
	      // discard translation; we just want to color sentence
	      if (((SentenceTreeNode) currentNode).toString().indexOf( tag) >= 0) {
		TreePath treePath
		  = new TreePath(new Object[] {root, currentNode,
					       currentNode.getChildAt(0),
					       currentNode.getChildAt(0).getChildAt(0)});
		jTree.setSelectionPath( treePath);

		// Center viewable screen on treepath
		java.awt.Dimension size = jTree.getParent().getSize();
		java.awt.Rectangle rect = jTree.getPathBounds( treePath);
		rect.grow((int) (size.getWidth() / 2),(int) (size.getHeight() / 2));
		jTree.scrollRectToVisible( rect);
		progress.setStringPainted(false);
		progress.setValue( limit);
		return;
	      } else
		jTree.collapsePath(new TreePath(new Object[] {root, currentNode}));
	    }
	    progress.setStringPainted(false);
	    progress.setValue( limit);
	    jTree.clearSelection();
	  }
	};
      worker.start();
    }
  }


  private TextDialog helpDialog = new TextDialog("Help", getProperties());

  class HelpAction extends AbstractAction {
    public HelpAction() {super( "help");}

    public void actionPerformed(ActionEvent e) {
      helpDialog.setURL( Tester.class.getResource( Tester.class.getName() + ".html"));
      helpDialog.setState( java.awt.Frame.NORMAL);
      helpDialog.setVisible( true);
    }
  }


  private BaseAction baseAction = new BaseAction();

  /**
   * Class for changing the analyzer & generator servers being utilized
   */
  private class ServersAction extends AbstractAction {
    /**
     * Constructor to name the action
     */
    ServersAction() {super("servers");}

    public void actionPerformed(ActionEvent e) {
      ConnectionConfig.showDialog( new Connection[] {
	ksdb.getKantooConnectionWorker( true).getConnection(),
	ksdb.getKantooConnectionWorker( false).getConnection()});
      initServers();
    }
  }

  private ServersAction serversAction = new ServersAction();

  /**
   * Fetch the list of actions supported by this
   * editor.  It is implemented to return the list
   * of actions supported by the embedded JTextComponent
   * augmented with the actions defined locally.
   *
   * @return actions suported by this editor
   */
  public Action[] getActions() {
    Action[] defaultActions = {new ExitAction(),
			       serversAction,
			       baseAction,
			       new AnalyzeAction(),
			       new GenerateAction(),
			       new CopyAction(),
			       new ScoreAction(),
			       new CommentAction(),
			       new NextAction("nextUnknown", unknownMarkup),
			       new NextAction("nextIncorrect",incorrectMarkup),
			       new NextAction("nextChanged", changedMarkup),
			       new HelpAction(),
    };
    return defaultActions;
  }


  // The tree

  static final String HTML = "<html>";

  String correctMarkup = getProperties().get("markup.correct");
  String incorrectMarkup = getProperties().get("markup.incorrect");
  String changedMarkup = getProperties().get("markup.changed");
  String unknownMarkup = getProperties().get("markup.unknown");
  String oldMarkup = getProperties().get("markup.old");
  String labelMarkup = getProperties().get("markup.label");
  String labelUnmarkup = "</" + labelMarkup.substring(1);
  String diffMarkup = getProperties().get("markup.diff");
  String diffUnmarkup = "</" + diffMarkup.substring(1);

  /**
   * Provides appropriate markup for a score
   */
  public String markup(String score) {
    return ksdb.correct( score) ? correctMarkup :
      ksdb.incorrect( score) ? incorrectMarkup :
      unknownMarkup;
  }


  /**
   * This takes two HTML strings, and inserts a markup between them for
   * each case where they differ.
   *
   * The algorithm here was devised by Bob Igo in his regression-test.pl
   * program. It breaks both strings up into word lists, and then traverses
   * through each word list, adding like words to 2 output strings. When
   * a difference is encountered, the algorithm looks for the next matching
   * pair of words in the sentences, such that the sum of the indices of the
   * matching words is minimized. It highlights the differences found, and 
   * then continues walking down the list starting from the matching pair.
   *
   * Does not return anything, but replaces the two strings in the input
   * array with two marked-up strings.
   *
   * @param strings Array of exactly two strings to check for diffs
   */
  public void markupDiff(String[] strings) {
    final int limit = strings.length; // should always be 2
    List[] tokens = new List[ limit];
    StringBuffer[] results = new StringBuffer[ limit];
    int[] indices = new int[ limit];

    for (int i = 0; i < limit; i++) {
      results[i] = new StringBuffer();
      indices[i] = 0;

      tokens[i] = new ArrayList();
      StringTokenizer str = new StringTokenizer( strings[i], " ");
      while (str.hasMoreTokens())
	tokens[i].add( str.nextToken());
    }


    while (indices[0] < tokens[0].size() && indices[1] < tokens[1].size()) {
      if (tokens[0].get( indices[0]).equals( tokens[1].get( indices[1])))
	for (int i = 0; i < limit; i++) {
	  if (results[i].length() > 0) results[i].append(' ');
	  results[i].append( tokens[i].get( indices[i]));
	}
      else {

	int next[] = new int[ limit];
	for (int i = 0; i < limit; i++)
	  next[i] = tokens[i].size();

	// Find the minimum indices greater than indices where the two
	// strings have matching words
	int test[] = new int[ limit];
	for (test[0] = indices[0]; test[0] < tokens[0].size(); test[0]++)
	  for (test[1] = indices[1]; test[1] < tokens[1].size(); test[1]++)
	    if (tokens[0].get( test[0]).equals( tokens[1].get( test[1])))
	      if (test[0] + test[1] < next[0] + next[1])
		for (int i = 0; i < limit; i++)
		  next[i] = test[i];

	for (int i = 0; i < limit; i++)
	  if (next[i] > indices[i]) {
	    if (results[i].length() > 0) results[i].append(' ');
	    results[i].append( diffMarkup);
	    for (int index = indices[i]; index < next[i]; index++) {
	      if (index > indices[i]) results[i].append(' ');
	      results[i].append( tokens[i].get( index));
	    }
	    results[i].append( diffUnmarkup);
	    indices[i] = next[i];
	  }

	for (int i = 0; i < limit; i++)
	  if (next[i] < tokens[i].size()) {
	    if (results[i].length() > 0) results[i].append(' ');
	    results[i].append( tokens[i].get( next[i]));
	  }
      }

      for (int i = 0; i < limit; i++)
	indices[i]++;
    }

    for (int i = 0; i < limit; i++)
      strings[i] = results[i].toString();
  }



  /// All tree nodes

  abstract class TesterTreeNode extends LazyTreeNode {
    protected String text = null;
    protected String markupText = null;
    protected String markup = null;

    public TesterTreeNode() {super();}
    public TesterTreeNode(String markup, String text) {
      super();
      this.text = text;
      this.markup = markup;
    }

    /**
     * Converts s into HTML
     *
     * @param  s string to be converted
     * @return   converted string
     */
    public String escapeBrackets(String s) {
      StringBuffer result = new StringBuffer();
      char c;
      for (int i = 0; i < s.length(); i++) {
	c = s.charAt(i);
	if (c == '<') result.append("&lt;");
	else if (c == '>') result.append("&gt;");
	else result.append(c);
      }

      return new String(result);
    }

    public String toString() {
      if (markupText != null)
	return HTML + markup + markupText;
      if (markup == null) return text;
      else  return HTML + markup + escapeBrackets( text);
    }

    public String getText() {return text;}


    public void setMarkup(String score, boolean changed) {
      this.markup = markup( score);
      if (changed) this.markup += changedMarkup;
      baseAction.setEnabled(false);
      serversAction.setEnabled(false);
    }

    public void setMarkup(String markup) {
      this.markup = markup;
    }

    public void setText(String text) {
      this.text = text;
    }

    public void setMarkupText(String text) {
      this.markupText = text;
    }
  }

  class TesterDataTreeNode extends TesterTreeNode {
    public TesterDataTreeNode(String text) {this( null, text);}
    public TesterDataTreeNode(String markup,String text) {super(markup, text);}
    protected void realizeChildren() {}
  }

  private final TesterDataTreeNode noCurrentTranslations
    = new TesterDataTreeNode( markup("UNKNOWN") + oldMarkup,
			      "No translations in current version!");
  private final TesterDataTreeNode noBaseTranslations
    = new TesterDataTreeNode( markup("UNKNOWN") + oldMarkup,
			      "No translations in base version!");
  private final TesterDataTreeNode noCurrentIRs
    = new TesterDataTreeNode(markup("UNKNOWN") + oldMarkup,
			     "No interlinguas in current version!");
  private final TesterDataTreeNode ungrammaticalCurrentSentence
    = new TesterDataTreeNode(markup("INCORRECT") + oldMarkup,
			     "Sentence is ungrammatical in current version!");


  class AllSentencesTreeNode extends TesterTreeNode {
    public AllSentencesTreeNode() {super( labelMarkup, "Sentences");}

    protected void realizeChildren() {
      int value = 0;
      for (SQLConnection.beforeFirst(sentences); SQLConnection.next( sentences);) {
	this.add(new SentenceTreeNode(SQLConnection.getInt(sentences, 1),
				      SQLConnection.getString( sentences, 2),
				      SQLConnection.getInt(sentences, 3)));
	progress.setValue( progress.getValue() + 1);
      }
    }
  }

  class SentenceTreeNode extends TesterTreeNode {
    int id;
    int previous;

    public SentenceTreeNode(int id, String text, int previous) {
      super( null, text);
      this.id = id;
      this.previous = previous;
    }

    protected void realizeChildren() {
      add(new TranslationsTreeNode());

      // Context
      if (previous != 0)
	add(new ContextTreeNode());
      progress.setValue( progress.getValue() + 1);
    }
  }

  class ContextTreeNode extends TesterTreeNode {
    public ContextTreeNode() {super( labelMarkup, "Anaphor Context");}

    protected void realizeChildren() {
      int context = ((SentenceTreeNode) getParent()).previous;
      while (context != 0) { // always true at least once
	String contextText = ksdb.selectString("text FROM Sentence WHERE id="
					       + context);
	this.insert(new TesterDataTreeNode( contextText), 0);
	context = ksdb.selectInt("previous FROM Sentence WHERE id=" + context);
      }
    }
  }


  abstract class ScorablesTreeNode extends TesterTreeNode {
    public ScorablesTreeNode() {super( labelMarkup, null);}

    protected void setLine() {
      setText( getLanguageName() + ' ' + getKind() + 's');
    }

    protected ScorableTreeNode current = null;
    protected ScorableTreeNode correct = null;

    protected abstract String getKind(); 
    protected abstract String getLanguageName();
    protected abstract VersionsTreeNode getVersionsTreeNode();
    protected abstract void handleNoChildren();
    protected abstract String getRestOfSelection();
    protected abstract ScorableTreeNode getScorableTreeNode(int id,
							    String score,
							    String comment,
							    String text);

    protected void realizeChildren() {
      // First add all version nodes
      VersionsTreeNode vtn = getVersionsTreeNode();
      add( vtn);

      // Now add CORRECT interlingua node
      ResultSet rs = ksdb.select(getKind() + ".id, " + getKind() + ".score, " +
				 getKind() + ".comment, " + getKind() + ".text"
				 + " FROM " + getKind() +getRestOfSelection());
      if (SQLConnection.next(rs)) {
	correct = getScorableTreeNode(SQLConnection.getInt(rs, 1), SQLConnection.getString(rs, 2),
				      SQLConnection.getString(rs, 3), SQLConnection.getString(rs, 4));
	insert( correct, 0);
      }

      // Now add current scorable node
      // (This relies on the fact that the first child is current server
      // version and first grandchild is 'best' scorable node.)
      try {
	current = (ScorableTreeNode) ((ScorableTreeNode) (vtn.getChildAt(0).getChildAt(0))).clone();
	// don't add, if this one is correct
	if (current == null)
	  handleNoChildren();
	else if (correct == null || current.id != correct.id)
	  insert( current, 0);
	// else do nothing, current == correct
      } catch (ArrayIndexOutOfBoundsException x) {handleNoChildren();}
    }
  }

  class TranslationsTreeNode extends ScorablesTreeNode {
    protected ScorableTreeNode base = null;

    public TranslationsTreeNode() {
      super();
      setLine();
    }

    /**
     * @return ID of source sentence associated with this translation
     */
    private int getSource() {return ((SentenceTreeNode) getParent()).id;}


    protected String getKind() {return "Translation";}
    protected String getLanguageName() {return targetLanguageName;}
    protected VersionsTreeNode getVersionsTreeNode() {
      return new GeneratorVersionsTreeNode();
    }
    protected TesterDataTreeNode noCurrentNode() {return noCurrentTranslations;}

    protected void realizeChildren() {
      super.realizeChildren();
      int sourceSentence = ((SentenceTreeNode) (this.getParent())).id;
      ResultSet rs;

      // Make sure current translation is really current
      if (current != null) {
	rs = ksdb.select("Translation.id"
		   + " FROM Translation, Generation, Analysis"
		   + " WHERE Translation.source=Analysis.sentence"
		   + " AND Analysis.interlingua=Generation.interlingua"
		   + " AND Generation.translation=Translation.id"
		   + " AND Translation.id=" + current.id
		   + " AND Translation.source=" + sourceSentence
		   + " AND Analysis.version=" + currentAnalyzerVersion
		   + " AND Generation.version=" + currentGeneratorVersion);
	if (!SQLConnection.next(rs)) { // oops, it isn't!
	  if ((correct == null) || (current.id != correct.id))
	    remove(0);
	  current = null;
	  handleNoChildren();
	}}

      // Figure out base generator/analyzer translation, if necessary
      boolean baseNull = false;
      if ((baseAnalyzerVersion != currentAnalyzerVersion) ||
	  (baseGeneratorVersion != currentGeneratorVersion)) {
	rs = ksdb.select("Translation.id, Translation.score,"
			+ " Translation.comment, Translation.text, Generation.timestamp"
			+ " FROM Translation, Generation, Analysis"
			+ " WHERE Translation.source=Analysis.sentence"
			+ " AND Analysis.interlingua=Generation.interlingua"
			+ " AND Generation.translation=Translation.id"
			+ " AND Translation.source=" + sourceSentence
			+ " AND Analysis.version=" + baseAnalyzerVersion
			+ " AND Generation.version=" + baseGeneratorVersion);

	// Figure out which row has 'best' translation
	String best = null;
	while (SQLConnection.next(rs))
	  if (best == null || AnalyzerConnection.compareTranslations( SQLConnection.getString(rs, 4), best) < 0)
	    best = SQLConnection.getString(rs, 4);
	SQLConnection.beforeFirst(rs);
	while (SQLConnection.next(rs))
	  if (best.equals( SQLConnection.getString(rs, 4)))
	    break;

	if (best == null) { // No translations in base version!
	  insert( noBaseTranslations, 1);
	  baseNull = true;

	// Insert baseline sentence, unless it matches CORRECT or CURRENT
	} else if (current == null ||
		   SQLConnection.getInt(rs, 1) != current.id) {
	  base = new TranslationTreeNode( SQLConnection.getInt(rs, 1), SQLConnection.getString(rs, 2), SQLConnection.getString(rs, 3), sourceSentence, SQLConnection.getString(rs, 4), baseGeneratorVersion, SQLConnection.getTimestamp(rs, 5));

	  if (correct != null && correct.id == base.id)
	    base = correct;
	  else
	    insert( base, 1); // after current, before correct
	} // else base remains null
      }

      // Color source sentence same as current, highlight if distinct from base
      boolean changed = ((baseNull == true) != (current == null)) ||
	((base != null) && (current != null) && (base.id != current.id));
      ((SentenceTreeNode) (this.getParent())).setMarkup(((current == null) ? "UNKNOWN" : current.score), changed);
      if (current == null) return;

      // Add diff markups to current & base sentence if they are distinct
      TranslationTreeNode n1 = (TranslationTreeNode) current;
      TranslationTreeNode n2
	= (TranslationTreeNode) ((base != null && current.id != base.id) ? base
	 : (base == null && correct != null && current.id != correct.id)
				 ? correct : null);
      if (n2 != null) {
	String[] strings = new String[] { escapeBrackets( n1.text),
					  escapeBrackets( n2.text)};
	markupDiff( strings);
	n1.setMarkupText( strings[0]);
	n2.setMarkupText( strings[1]);
      }
    }

    protected void handleNoChildren() {
      ResultSet rs = ksdb.select("interlingua FROM Analysis"
				 + " WHERE sentence=" + getSource()
				 +" AND version=" + currentAnalyzerVersion);
      boolean changed = (base != null && (current == null || base.id != current.id));
      if (!SQLConnection.next(rs)) {
	insert(noCurrentIRs, 0);
	((SentenceTreeNode) (this.getParent())).setMarkup( "UNKNOWN", changed);
      } else if (SQLConnection.getInt(rs, 1) == 0) {
	insert( ungrammaticalCurrentSentence, 0);
	((SentenceTreeNode) (this.getParent())).setMarkup( "BAD-IR", changed);
      } else {
	insert( noCurrentTranslations, 0);
	insert(new InterlinguasTreeNode( getSource(), 0, 0), 1);
	((SentenceTreeNode) (this.getParent())).setMarkup( "UNKNOWN", changed);
      }
    }

    protected String getRestOfSelection() {
      return ", Generation WHERE Translation.language=" + currentTargetLanguage
	+ " AND Translation.source=" + getSource()
	+ " AND Translation.id=Generation.translation"
	+ " AND Generation.version=" + correctGeneratorVersion;
    }

    protected ScorableTreeNode getScorableTreeNode(int id, String score,
					   String comment, String text) {
      return new TranslationTreeNode( id, score, comment, getSource(), text, 
				      correctGeneratorVersion, new Timestamp(0));
    }
  }

  class InterlinguasTreeNode extends ScorablesTreeNode {
    int source;
    int target; // 0 means don't distinguish on target
    int generatorVersion; // 0 means don't use any version

    public InterlinguasTreeNode(int source, int target, int generatorVersion) {
      super();
      setLine();
      this.source = source;
      this.target = target;
      this.generatorVersion = generatorVersion;
    }

    protected String getKind() {return "Interlingua";}
    protected String getLanguageName() {return sourceLanguageName;}
    protected VersionsTreeNode getVersionsTreeNode() {
      return new AnalyzerVersionsTreeNode();
    }
    protected void handleNoChildren() {insert( noCurrentIRs, 0);}

    protected String getRestOfSelection() {
      return ", Analysis WHERE Interlingua.sentence=" + source
	+ " AND Interlingua.id=Analysis.interlingua"
	+ " AND Analysis.version=" + correctAnalyzerVersion;
    }

    protected ScorableTreeNode getScorableTreeNode(int id, String score,
					   String comment, String text) {
      return new InterlinguaTreeNode( id, score, comment);
    }
  }


  /**
   * A class for displaying all versions of a KANTOO server
   */
  abstract class VersionsTreeNode extends TesterTreeNode {
    public VersionsTreeNode() {
      super( labelMarkup, null);
      setText(getKind() + " Versions");
    }

    protected abstract String getKind();
    protected abstract int getLanguage();
    protected abstract int getCurrentVersion();
    protected abstract MutableTreeNode createTreeNode(int id, String name);

    protected void realizeChildren() {
      ResultSet versions
	= ksdb.select("id, name FROM " + getKind() + "_Version WHERE language="
		      + getLanguage() + " ORDER BY timestamp DESC");
      String currentVersionName = null;
      while (SQLConnection.next( versions)) {
	if (SQLConnection.getInt( versions, 1) == getCurrentVersion())
	  currentVersionName = SQLConnection.getString( versions, 2);
	else if (!SQLConnection.getString( versions, 2).equals("CORRECT"))
	  add( createTreeNode( SQLConnection.getInt( versions, 1),
			       SQLConnection.getString( versions, 2)));
      }
      if (currentVersionName != null)
	insert( createTreeNode( getCurrentVersion(), currentVersionName), 0);
    }
  }

  class GeneratorVersionsTreeNode extends VersionsTreeNode {
    public GeneratorVersionsTreeNode() {super();}

    protected String getKind() {return "Generator";}
    protected int getLanguage() {return currentTargetLanguage;}
    protected int getCurrentVersion() {return currentGeneratorVersion;}
    protected MutableTreeNode createTreeNode(int id, String name) {
      return new GeneratorVersionTreeNode( id, name);
    }
  }

  class AnalyzerVersionsTreeNode extends VersionsTreeNode {
    public AnalyzerVersionsTreeNode() {super();}

    protected String getKind() {return "Analyzer";}
    protected int getLanguage() {return currentSourceLanguage;}
    protected int getCurrentVersion() {return currentAnalyzerVersion;}
    protected MutableTreeNode createTreeNode(int id, String name) {
      return new AnalyzerVersionTreeNode( id, name);
    }
  }


  /**
   * A node to display versions of a KANTOO server
   */
  abstract class VersionTreeNode extends TesterTreeNode {
    protected int id;
    protected abstract int getCurrentVersion();

    public VersionTreeNode(int id, String text) {
      super( null, text);
      this.id = id;
    }

    protected void realizeChildren() {
      /*
      Timestamp date = ksdb.selectTimestamp("timestamp FROM Analyzer_Version WHERE id=" + id);
      add(new TesterDataTreeNode(null, date));
      */
    }
  }

  class GeneratorVersionTreeNode extends VersionTreeNode {

    public GeneratorVersionTreeNode(int id, String text) {
      super(id, text);
      if (id == getCurrentVersion()) setMarkup( oldMarkup);
    }

    protected int getCurrentVersion() {return currentGeneratorVersion;}

    protected void realizeChildren() {
      super.realizeChildren();
      int sentenceID = ((SentenceTreeNode) getParent().getParent().getParent()).id;

      ResultSet rs
	= ksdb.select("Translation.id, Translation.score, Translation.comment, Translation.text, Generation.timestamp FROM Translation, Generation"
		      + " WHERE Translation.language=" + currentTargetLanguage
		      + " AND Translation.source=" + sentenceID
		      + " AND Translation.id=Generation.translation"
		      + " AND Generation.version=" + this.id);
      List translations = new ArrayList();
      while (SQLConnection.next(rs)) {
	TranslationTreeNode ttn = new TranslationTreeNode( SQLConnection.getInt(rs, 1), SQLConnection.getString(rs, 2), SQLConnection.getString(rs, 3), sentenceID, SQLConnection.getString(rs, 4), id, SQLConnection.getTimestamp(rs, 5));
	//	if (id != getCurrentVersion()) ttn.prefix = oldMarkup;
	translations.add( ttn);
      }
      java.util.Collections.sort( translations);

      // Add the trans from the current Analyzer first
      boolean best = false;
      for (Iterator i = translations.iterator(); i.hasNext();) {
	TranslationTreeNode ttn = (TranslationTreeNode) i.next();
	if (!best)
	  rs = ksdb.select("Translation.id"
			   + " FROM Translation, Generation, Analysis"
			   + " WHERE Translation.source=Analysis.sentence"
			   + " AND Analysis.interlingua=Generation.interlingua"
			   + " AND Generation.translation=Translation.id"
			   + " AND Translation.source=" + sentenceID
			   + " AND Translation.id=" + ttn.id
			   + " AND Analysis.version=" + currentAnalyzerVersion
			   + " AND Generation.version=" + currentGeneratorVersion);
	else rs = null;
	if (rs != null && SQLConnection.next(rs)) {
	  best = true;
	  this.insert( ttn, 0);
	} else
	  this.add( ttn);
      }
    }
  }

  class AnalyzerVersionTreeNode extends VersionTreeNode {

    public AnalyzerVersionTreeNode(int id, String text) {
      super( id, text);
      if (id == getCurrentVersion()) setMarkup( oldMarkup);
    }

    protected int getCurrentVersion() {return currentAnalyzerVersion;}

    protected void realizeChildren() {
      super.realizeChildren();

      int sourceID = ((InterlinguasTreeNode) getParent().getParent()).source;
      int targetID = ((InterlinguasTreeNode) getParent().getParent()).target;
      int generatorVersionID = 0;
      for (TreeNode tn = this; tn != null; tn = tn.getParent())
	if (tn instanceof TranslationTreeNode) {
	  generatorVersionID = ((TranslationTreeNode) tn).version;
	  break;
	}

      ResultSet rs;
      if (targetID == 0)
	rs = ksdb.select("Interlingua.id, Interlingua.score, Interlingua.comment FROM Analysis, Interlingua"
			 + " WHERE Analysis.sentence=" + sourceID
			 + " AND Analysis.version=" + this.id
			 + " AND Analysis.interlingua=Interlingua.id");
      else if (generatorVersionID == 0)
	rs = ksdb.select("Interlingua.id, Interlingua.score, Interlingua.comment FROM Analysis, Interlingua, Generation"
			 + " WHERE Analysis.sentence=" + sourceID
			 + " AND Analysis.version=" + this.id
			 + " AND Analysis.interlingua=Interlingua.id"
			 + " AND Generation.interlingua=Interlingua.id"
			 + " AND Generation.translation=" + targetID);
      else
	rs = ksdb.select("Interlingua.id, Interlingua.score, Interlingua.comment FROM Analysis, Interlingua, Generation"
			 + " WHERE Analysis.sentence=" + sourceID
			 + " AND Analysis.version=" + this.id
			 + " AND Analysis.interlingua=Interlingua.id"
			 + " AND Generation.interlingua=Interlingua.id"
			 + " AND Generation.translation=" + targetID
			 + " AND Generation.version=" + generatorVersionID);

      while (SQLConnection.next(rs)) {
	InterlinguaTreeNode itn = new InterlinguaTreeNode( SQLConnection.getInt(rs, 1), SQLConnection.getString(rs, 2), SQLConnection.getString(rs, 3));
	//	if (id != getCurrentVersion()) itn.prefix = oldMarkup;
	this.add( itn);
      }
    }
  }


  /**
   * A class for Translation nodes and Interlingua nodes that are scorable
   */
  abstract class ScorableTreeNode extends TesterTreeNode implements Cloneable {
    int id;
    String score;
    String comment;

    public ScorableTreeNode(int id, String score, String comment) {
      super();
      setMarkup( score, false);
      this.id = id;
      this.score = score;
      this.comment = comment;
    }

    abstract public String[] getPossibleScores();
    abstract public void changeScore(String newScore) throws SQLException;
    abstract public void changeComment(String newComment) throws SQLException;

    protected void realizeChildren() {
      add(new TesterDataTreeNode( labelMarkup + "Score: " + labelUnmarkup + markup(score), score));
      if (comment != null)
	add(new TesterDataTreeNode( labelMarkup + "Comment: " + labelUnmarkup, comment));
    }

    /**
     * Allows the user to change the score on this node
     */
    public void score() {
      // Put CORRECT at end of scores
      String[] scores = getPossibleScores();
      String[] reorderedScores = new String[ scores.length];
      boolean correctEncountered = false;
      for (int i = 0; i < scores.length; i++) {
	reorderedScores[i - (correctEncountered ? 1 : 0)] = scores[i];
	if (scores[i].equals("CORRECT"))
	  correctEncountered = true;
      }
      reorderedScores[ scores.length - 1] = "CORRECT";
      
      Object result = JOptionPane.showInputDialog( Tester.this, toString(), "Change Score", JOptionPane.PLAIN_MESSAGE, null, reorderedScores, score);
      if (result == null) return;

      try {changeScore((String) result);
      } catch (SQLException x) {ReportError.warning(x);}

      // Reload things from the sentences node
      TreeNode sentence = this;
      while (!(sentence instanceof SentenceTreeNode))
	sentence = sentence.getParent();
      resetTreeNode((SentenceTreeNode) sentence);
      jTree.requestFocus();
    }

    /**
     * Allows the user to change the comment on this node
     */
    public void comment() {
      Object result = JOptionPane.showInputDialog( Tester.this, toString(), "Change Comment", JOptionPane.PLAIN_MESSAGE, null, null, comment);
      if (result == null) return;

      try {changeComment((String) result);
      } catch (SQLException x) {ReportError.warning(x);}

      // Reload things from the sentences node
      TreeNode sentence = this;
      while (!(sentence instanceof SentenceTreeNode))
	sentence = sentence.getParent();
      resetTreeNode((SentenceTreeNode) sentence);
      jTree.requestFocus();
    }
  }

  /**
   * When sorting two translation nodes, order by time, unless they occur
   * very close in time (presumably from the same run of the analyzer).
   */
  private final int TIME_FUZZ_FACTOR = Integer.parseInt( properties.get("time.fuzz"));

  class TranslationTreeNode extends ScorableTreeNode implements Comparable {
    // This class is comparable so that its objects may be sorted
    int source;
    String tlText;
    int version;
    Timestamp timestamp;

    public TranslationTreeNode(int id, String score, String comment,
			       int source, String tlText, int version,
			       Timestamp timestamp) {
      super(id, score, comment);
      this.source = source;
      this.tlText = tlText;
      this.version = version;
      this.timestamp = timestamp;
      setText( tlText);
    }

    public String[] getPossibleScores() {return ksdb.TransScores;}

    public void changeScore(String newScore) throws SQLException {
      ksdb.rateTranslation( id, newScore);
    }
    public void changeComment(String newComment) throws SQLException {
      ksdb.commentTranslation( id, newComment);
    }

    public int compareTo(Object o) {
      Timestamp thatTimestamp = ((TranslationTreeNode) o).timestamp;
      int result = this.timestamp.compareTo( thatTimestamp);
      long diff = Math.abs( this.timestamp.getTime() - thatTimestamp.getTime());
      if ((diff > TIME_FUZZ_FACTOR) && (result != 0)) return - result;
      return AnalyzerConnection.compareTranslations( this.tlText,
			    ((TranslationTreeNode) o).tlText);
    }


    protected void realizeChildren() {
      super.realizeChildren();
      add(new InterlinguasTreeNode(source, id, version));
    }
  }

  class InterlinguaTreeNode extends ScorableTreeNode {
    public InterlinguaTreeNode(int id, String score, String comment) {
      super( id, score, comment);
      setText("IR ID=" + id);
    }

    public String[] getPossibleScores() {return ksdb.IRScores;}

    public void changeScore(String newScore) throws SQLException {
      ksdb.rateInterlingua( id, newScore);
    }
    public void changeComment(String newComment) throws SQLException {
      ksdb.commentInterlingua( id, newComment);
    }

    protected void realizeChildren() {
      super.realizeChildren();
      add(new InterlinguaDataTreeNode("IR Data", ksdb.selectString("text FROM Interlingua WHERE id=" + id)));
    }
  }


  /**
   * S has an open paren at i. Return index of matching closed paren
   */
  public static int findMatching(String s, int i) {
    int count = 1;
    while (i++ < s.length() && count > 0) {
      if (s.charAt(i) == '(') count++;
      if (s.charAt(i) == ')') count--;
    }
    if (i == s.length()) return -1;
    return i;
  }

  class InterlinguaDataTreeNode extends TesterTreeNode {
    String slot;
    String ir;

    public InterlinguaDataTreeNode(String slot, String ir) {
      super( null, slot);
      this.ir = ir;
    }

    protected void realizeChildren() {
      // First add concept node
      int newline = ir.indexOf('\n');
      if (newline == -1) {
	add(new TesterDataTreeNode(ir.substring(1, ir.length() - 1)));
	return;
      }
      String term = ir.substring(1, newline);
      add(new TesterDataTreeNode( term));
      boolean mult = (term.startsWith(":MULTIPLE") || term.startsWith(":OR"));

      // Now add slot-value nodes
      int prevClose = newline;
      for (int close = ir.indexOf(')'); close != -1; close = ir.indexOf(')', close+1)) {
	if (close == prevClose + 1) break;

	int open = ir.indexOf('(', prevClose);
	int subOpen = ir.indexOf('(', open+1);

	if (mult) {
	  close = findMatching( ir, open);
	  add(new InterlinguaDataTreeNode( "CONJUNCT",
					   ir.substring( open, close)));
	} else if (subOpen > close || subOpen == -1)
	  add(new TesterDataTreeNode( ir.substring( open+1, close)));
	else {
	  close = findMatching( ir, open);
	  int slotEnd = subOpen-1;
	  while (Character.isWhitespace( ir.charAt( slotEnd))) slotEnd--;
	  add(new InterlinguaDataTreeNode( ir.substring( open+1, slotEnd + 2), 
					   ir.substring( subOpen, close - 1)));
	  close++;
	}
	prevClose = close;
      }
    }
  }


  // Command-line invocation

  /**
   * Returns the language id given the name
   * @param  ksdb         KANTOO Sentence Database object
   * @param  name         Name of language
   * @return              ID of language
   * @throws SQLException if language doesn't exist in KSDB
   */
  private static int getLanguage(Ksdb ksdb, String name) throws SQLException {
    int id = ksdb.selectInt("id FROM Source_Language WHERE name="
			    + SQLConnection.quote( name.toLowerCase()));
    if (id == 0)
      throw new SQLException("Language name=" + name
			     + " doesn't exist in KSDB.");
    return id;
  }

  /**
   * Returns the document id given the language and filename
   * @param  ksdb         KANTOO Sentence Database object
   * @param  langauge     Name of language
   * @param  filename     Name of document file
   * @return              ID of document
   * @throws SQLException if document doesn't exist in KSDB
   */
  private static int getDocument(Ksdb ksdb, String language, String filename)
    throws SQLException {
    int languageID = getLanguage( ksdb, language);
    int id = ksdb.selectInt("id FROM Document WHERE language=" + languageID
			    + " AND filename=" +SQLConnection.quote(filename));
    if (id == 0)
      throw new SQLException("Document file=" + filename
			     + " doesn't exist in KSDB.");
    return id;
  }

  /**
   * Joins all the arguments after arg (inclusive) in a space-separated string
   * @param args Args sent to program
   * @param arg  First arg of string
   * @return String joining all args. may be NULL if no args provided
   */
  private static String argsRemainder(String[] args, int arg) {
    StringBuffer result = new StringBuffer();
    boolean space = false;
    for (int i = arg; i < args.length; i++) {
      if (space) result.append(' ');
      else space = true;
      if (args[i] != null) result.append( args[i]);
    }
    return result.length() == 0 ? null : new String(result);
  }

  /**
   * Main procedure. Locks sentences before testing them
   */
  public static void main(String[] args) {
    PropertyManager.getArgs( args);
    String select = args[0];
    Tester tester = null;
    try {
      Ksdb ksdb = new Ksdb(System.getProperty("user.name"));
      System.err.println("WARNING: This method is not lock-safe!");

      if (select.equals("select")) {
	String selectCmd = argsRemainder( args, 1);
	tester = Tester.createFrame( "Testing sentences " + selectCmd, 
				     ksdb, 0, selectCmd);
      } else {
	int document = getDocument(ksdb,args[0],args[1]);
	tester = Tester.createFrame("Testing document " + args[1],
				    ksdb, document, null);
      }

    } catch (Exception x) {ReportError.warning(x);}

    tester.frame.setVisible( ReportError.Windows);
  }
}

