/* Copyright (c) 2008 Charles Rich and Worcester Polytechnic Institute.
 * All Rights Reserved.  Use is subject to license terms.  See the file
 * "license.terms" for information on usage and redistribution of this
 * file and for a DISCLAIMER OF ALL WARRANTIES.
 */
package edu.wpi.cetask.guide;

import java.beans.Statement;
import java.io.*;
import java.lang.reflect.InvocationTargetException;
import java.text.DateFormat;
import java.util.*;

import javax.script.*;

import edu.wpi.cetask.*;

// TODO add stack to handle interruptions

public class Guide {
   
   public static String VERSION = "0.8.2 beta";

   public static void main (String[] args) {
      // for convenience to start both Guide and SpeechGuide from same jar file
      new Guide(args.length > 0 ? args[0] : null).loop();
   }
   
   protected final TaskEngine engine = new TaskEngine();
   
   public TaskEngine getEngine () {
	   return engine;
   }

   private final PrintStream out = System.out, err = System.err; // to restore                             
   private final File log;

   private BufferedReader input; // current input
   private File file;   // current input source file (or null)
   
   public Guide (String filename) {
      if ( filename == null ) input = systemIn();
      else source(filename);
      log = new File(System.getProperty("java.io.tmpdir") + '/'
         + (file == null ? "console" : file.getName()) + ".guide");
   }

   /**
    * Command loop. Uses reflection to invoke commands; see methods with command
    * names.
    */
   protected void loop () {
      PrintStream logStream = null;
      try {
         logStream = new PrintStream(new BufferedOutputStream(
               new FileOutputStream(log)), true);
         if ( file == null ) {
            println("# Writing console log to " +log+ "...");
            copyOutput(logStream);
         } else { System.setOut(logStream); System.setErr(logStream); }
         long now = System.currentTimeMillis();
         engine.setStart(now);
         println("# "
               +DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
                  .format(new Date(now))
               +" - Version "+VERSION);
         respond("@welcome");
         while (true) { 
            System.out.print(PROMPT);
            String line = input.readLine();
            if ( line == null && file != null ) {
               // switch input back to user
               file = null;
               copyOutput(logStream);
               input = systemIn(); 
               line = input.readLine();
            }
            if ( line == null ) break;
            if ( line.startsWith(PROMPT) ) 
               // for convenience to make source files using keep-lines in Emacs
               line = line.substring(PROMPT.length());
            (file == null || System.out == logStream ? logStream : System.out)
               .println(line);
            if ( line.startsWith("#") ) continue;  // skip comment line
            processCommand(line);
         }
      } 
      catch (Throwable e) { 
         if ( e instanceof Quit ) deallocate();
         else exception(e); 
      } finally { 
         System.setOut(out); System.setErr(err);
         if ( logStream != null) logStream.close(); 
      }
   }
   
   protected static class Quit extends Throwable {};
   
   protected void deallocate () {} // for SpeechGuide

   public void processCommand (String line) throws Throwable {
      StringTokenizer tokenizer = new StringTokenizer(line);
      if ( tokenizer.hasMoreTokens() ) {
         command = tokenizer.nextToken().toLowerCase();
         if ( command.equals("quit") ) {
            respond("@goodbye");
            throw new Quit();
         }
         try {
            // before next user turn
            engine.decomposeAll(); 
            // dispatch to command method
            new Statement(this, command, 
                  new Object[] { line.substring(command.length()).trim() }) 
            .execute();
         } catch (NoSuchMethodException m) {
            respond("@invalid");
         } catch (InvocationTargetException t) {
            throw t.getTargetException();
         } catch (Exception e) { exception(e); }
         system(); // system turn
      }
   }
   
   public static String PROMPT = "CE> ";
   
   private static BufferedReader systemIn () { 
      return new BufferedReader(new InputStreamReader(System.in));
   }
   
   private void copyOutput (PrintStream logStream) {
      System.setOut(new PrintStream(new CopyOutputStream(out, logStream), true));
      System.setErr(new PrintStream(new CopyOutputStream(err, logStream), true));
   }
   
   // see default.properties for system conversational utterances
   // TODO internationalize using resource bundles
   
   private void respondq (String key, TaskClass type, String name) {
      String string;
      if ( engine.DEBUG )
         string = engine.getProperty("@definite")+' '+type+' '+name;
      else {
         String property = type.getProperty("."+name+"@definite");
         string = property != null ? property : 
            engine.getProperty("@definite")+' '
            +Utils.decapitalize(type.getSlotType(name)); 
      }
      println(engine.getProperty(key)+' '+string+'?');
   }
   
   private void respond (String key, Plan plan) {
      respond(key, plan.getGoal());
   }

   private void respond (String key, Task task) {
      respond(key, task, false);
   }

   private void respondq (String key, Task task) {
      respond(key, task, true);
   }
   
   private void respond (String key, Task task, boolean question) {
      respond(key, engine.DEBUG ? task : task.format(), question);
   }
   
   private void respond (String key, Object object, boolean question) {
      println(engine.getProperty(key)+' '+object+(question ? '?' : '.'));
   }
   
   protected void respond (String key) {
      println(engine.getProperty(key));
   }

   public String response = "";
   
   protected void println (Object object) {
      System.out.print("    ");
      System.out.println(object);
      response = object.toString();
   }
   
   protected static void warning (String text) {
      System.err.println("WARNING: "+text);
   }
   
   protected static void exception (Throwable e) {
      if ( TaskEngine.DEBUG ) throw new RuntimeException(e);
      //else System.out.println(e);
      else e.printStackTrace();
   }
 
   // trivial discourse state
   
   protected Plan proposed, accepted, rejected;
   
   protected String command;

   /**
    * Method for system turn.  Called once each time around command loop.
    * Intended extension point.
    */
   protected void system () {
      if ( !status.contains(command) ) {
         if ( accepted != null && isSystem(accepted) ) {
            accepted.execute();
            // note some outputs may be undefined (especially if task failed)
            if ( accepted.isDone() ) respond("@done", accepted.getGoal());
            accepted = null;
            completed();
            updateFocus();
         } else if ( proposed == null ) { // unless outstanding proposal
            for (Plan next : getLive()) {
               if ( next != rejected && isSystem(next) ) {
                  respondq("@shall", next.getGoal());
                  propose(next);
                  break;
               }
            }
         }
      }
   }

   private final List<String> status = Arrays.asList(new String[] { 
      "load", "status", "eval", "source", "verbose", "debug", "help" });

   private boolean completed () {
      Plan focus = engine.getFocus();
      Plan top;
      if ( focus != null ) {
         if ( focus.isDone() && !focus.occurred() ) {
            respond("@completed", focus);
            return true;
         } else if ( focus.isFailed() ) {
            respond("@failed", focus);
            return true;
         } else if ( (top = engine.getTop(focus)).isDone() ) {
            // special case for completed toplevel plan
            respond("@completed", top);
            return true;
         }
      }
      return false;
   }
   
   private void updateFocus () {
      Plan focus = engine.getFocus();
      if ( focus != null && !focus.isLive() ) 
         setFocus(engine.newFocus(focus));
   }

   private List<Plan> getLive () {
      Plan focus = engine.getFocus();
      if ( focus == null ) return Collections.emptyList();
      List<Plan> live = focus.getLiveDescendants();
      if ( focus.isLive() ) live.add(focus);
      if ( live.isEmpty() ) live = engine.getTop(focus).getLive();
      return live;
   }
  
   private static boolean isSystem (Plan plan) {
      Task task = plan.getGoal();
      // TODO preference for external false (over null)?
      return plan.isLive() && !Utils.isTrue(task.getExternal())
         // note deciding here that decompositions take precedence
         && task.getType().isPrimitive() && task.getScript() != null 
         // TODO ask for input values if needed
         // user can provide inputs using 'task' command
         && task.isDefinedInputs(); 
   }
   
   protected void propose (Plan plan) {
      proposed = plan;
      if ( plan != null ) setFocus(plan);
      accepted = null;
      rejected = null;
   }
           
   // commands 
   
   public void help (String ignore) {
      help();
      System.out.println("    help                - show this message");
      System.out.println("    (See edu.wpi.cetask.guide.Guide javadoc for more information)");
   }
   
   protected void help () {
      System.out.println("    load <uri>          - load xml for task model");
      System.out.println("    task <id> [<namespace>] [ / <value> ]*");
      System.out.println("                        - set current task");
      System.out.println("                          (namespace defaults to last loaded)");
      System.out.println("                          (slot values optional)");
      System.out.println("    next                - tell me what to do next");
      System.out.println("    by <id>             - select decomposition class for current task");
      System.out.println("    done [<id> [<namespace>]] [ / <value> ]*");
      System.out.println("                        - I have performed this task");
      System.out.println("                          (defaults to suggested next task)");
      System.out.println("                          (all slot values required)");
      System.out.println("    execute [<id> [<namespace>]] [ / <value> ]*");
      System.out.println("                        - Like 'done', except runs script if any");
      System.out.println("    no                  - I don't want to do suggested next task");
      System.out.println("    yes                 - answer to yes/no question");
      System.out.println("    status              - print out current task tree");
      System.out.println("    clear               - delete all current tasks");
      System.out.println("    eval <javascript>   - evaluate inline JavaScript");
      System.out.println("    source <filename>   - read command input from file");
      System.out.println("    quit                - quit program");
      System.out.println("    verbose [<boolean>] - turn verbose output on/off (default true)");
      System.out.println("    debug [<boolean>]   - turn debugging output on/off (default true)");
   }
   
   /**
    * Load, parse and validate specified task model.
    * 
    * @param uri for xml file containing task model description
    */
   public void load (String uri) {
      // verify model against schema
      if ( Utils.validate(uri) ) {
         TaskModel model = engine.parse(uri);
         if ( TaskEngine.VERBOSE ) 
            println("# Parsed and validated "+model);
      }
   }
   
   /**
    * Set current task focus. Namespace defaults to last loaded. JavaScript to
    * compute all <em>declared</em> input and output slot values is optional
    * (in order declared). Skipped slots can be specified by '/ /'.<br>
    * <br>
    * Hint: If Javascript contains '/', use 'eval' command to set temporary
    * variable and use variable in 'done'.
    * 
    * @param args &lt;id&gt [&lt;namespace&gt] [ / &lt;value&gt ]*
    */
   public void task (String args) {
      Task task = processTask(args, proposed, false);
      propose(null); // clear proposals, if any
      if ( task != null ) {
         List<Plan> matches = engine.findMatches(task, true);
         Plan focus = null;
         if ( matches.isEmpty() ) {
            engine.addTop(focus = new Plan(task));
            respond("@top", task);
         } else {
            focus = ambiguity(matches);
            focus.recognize(task);
            respond("@ok");
         }
         // always change focus
         setFocus(focus);
      }
   } 
   
   private Plan ambiguity (List<Plan> matches) {
      if ( matches.size() > 1 ) {
         String name = null;
         for (Plan plan : matches) {
            Task task = task = plan.getGoal();
            // ignore ambiguity among repeatable steps
            if ( task instanceof Decomposition.Step ) {
               Decomposition.Step step = (Decomposition.Step) task; 
               if ( name == null ) name = step.getName();
               else if ( !step.getName().equals(name) ) {
                  ambiguous(matches);
                  break;
               }
            } else {
               ambiguous(matches);
               break;
            }
         }
      }
      return matches.get(0);
   }
   
   private void ambiguous (List<Plan> matches) {
      warning("Ignoring ambiguity of "+matches.size());
      if ( TaskEngine.DEBUG ) System.err.println(matches);      
   }
   
   private void setFocus (Plan focus) {
      Plan old = engine.getFocus();
      if ( engine.setFocus(focus) && !old.isFailed() ) 
         // unexpected focus shift
         respond("@shift", old);
   }
   
   /**
    * Select decomposition class for current focus task.
    * 
    * @param args &lt;id&gt
    */
   public void by (String args) {
      Plan focus = engine.getFocus();
      if ( focus != null ) {
        Task goal = focus.getGoal();
        DecompositionClass decomp = goal.getType().getDecomposition(args.trim());
        if ( decomp != null && !Utils.isFalse(decomp.isApplicable(goal)) ) {
           focus.apply(decomp);
           return;
        }
      }
      warning("No such decomposition for current focus.");
   }
   
   /**
    * Report user performance of primitive task. Task class defaults to
    * suggested next task. JavaScript to compute all <em>declared</em> input
    * and output slot values is required (in order declared). Success slot value is
    * optional (and last). Skipped slots can be specified by '/ /'.<br>
    * <br>
    * Hint: If Javascript contains '/', use 'eval' command to set temporary
    * variable and use variable in 'done'.
    * 
    * @param args [&lt;id&gt [&lt;namespace&gt]] [ / &lt;value&gt ]*
    */
   public void done (String args) {
      Task occurrence = processTaskIf(args, proposed, true);
      if ( occurrence != null ) done(occurrence); 
   }
   
   private void done (Task occurrence) {
      if ( proposed != null ) {
         Task task = proposed.getGoal();
         if ( task.matches(occurrence) )
            task.copySlotValues(occurrence);
      }
      // leave "success" undefined (see doc for Task.isDefinedOutputs)
      occurrence.setExternal(true); 
      occurrence.setWhen(System.currentTimeMillis());
      if ( occurrence.isDefinedInputs() ) {
         List<Plan> matches = engine.findMatches(occurrence, true);
         if ( matches.isEmpty() ) {
            respond("@unknown");
            Plan plan = new Plan(occurrence),
            focus = engine.getFocus();
            if ( focus == null ) engine.addTop(plan);
            else focus.add(plan);
         } else {
            Plan match = ambiguity(matches);
            match.recognize(occurrence);
            if ( !completed() ) respond("@ok");
            setFocus(match);
         }
         updateFocus();
      } else warning("All input values must be defined--ignored.");
      propose(null); // clear proposals, if any
   }
 
   /**
    * Like 'done', but first executes script associated with primitive task, if
    * any. Convenient for running simulations.
    * 
    * @see #done(String)
    */
   public void execute (String args) {
      Task occurrence = processTaskIf(args, proposed, true);
      if ( occurrence != null ) {
         if ( occurrence.isDefinedInputs() ) {
            Script script = occurrence.getScript();
            if ( script != null) script.eval(occurrence);
            done(occurrence);
         } else warning("All input values must be defined--ignored.");
      }
   }
   
   private Task processTaskIf (String args, Plan plan, boolean success) {
      Task occurrence = processTask(args, proposed, true);
      if ( occurrence == null ) 
         warning("Missing task argument (no task proposed).");
      return occurrence;
   }
   
   private Task processTask (String args, Plan plan, boolean success) {
      TaskClass type = plan == null ? null : plan.getGoal().getType(); 
      StringTokenizer tokenizer = new StringTokenizer(args, "/");
      if ( tokenizer.hasMoreTokens() && !args.startsWith("/") ) {
         String namespace = TaskEngine.getNamespace();
         StringTokenizer name = new StringTokenizer(tokenizer.nextToken());
         if ( name.hasMoreTokens() ) {
            String id = name.nextToken();
            if ( name.hasMoreTokens() ) namespace = name.nextToken();
            if ( name.hasMoreTokens() ) 
               warning("Ignoring '"+name.nextToken()+"' (and following)");
            type = engine.getModel(namespace).getTaskClass(id);
         }
      }
      if ( type == null ) return null;
      Task task = type.newInstance();
      // process input, output and success slot values, if any
      for (String name : type.getDeclaredInputNames()) 
         if ( !nextArg(tokenizer, task, name) ) return task;
      for (String name : type.getDeclaredOutputNames()) 
         if ( !nextArg(tokenizer, task, name) ) return task;
      if ( success ) nextArg(tokenizer, task, "success");
      if ( tokenizer.hasMoreTokens() ) 
         warning("Ignoring '"+tokenizer.nextToken()+"' (and following)");
      return task;
   }

   private static boolean nextArg (StringTokenizer tokenizer, 
                                   Task task, String name) {
      if ( tokenizer.hasMoreTokens() ) {
         String next = tokenizer.nextToken().trim();
         if ( next.length() > 0 )
            task.setSlotValueScript(name, next, "Guide");
         return true;
      } else return false;
   }
   
   /**
    * Advise the user what needs to be done next.
    */
   public void next (String ignore) {
      completed();
      List<Plan> live = getLive();
      // remove rejected options
      for (Iterator<Plan> i = live.iterator(); i.hasNext();) 
         if ( i.next() == rejected ) i.remove();
      // first preference is for system to do tasks for which it has all info
      for (Plan next : live) 
         if ( isSystem(next) ) {
            propose(null); // clear proposals, if any
            return;
         }
      // next preference is live user tasks
      for (Plan next : live) {
         Task task = next.getGoal();
         if ( task.getType().isPrimitive() && !Utils.isFalse(task.getExternal()) ) {
            respond((next.isOptional() ? "@optional" : "@please"), task);
            propose(next);
            return;
         }
      }
      // next preference is to ask for unbound input
      for (Plan next : live) {
         Task task = next.getGoal();
         TaskClass type = task.getType();
         for (String name : type.getDeclaredInputNames())
               if ( !task.isDefinedSlot(name) ) {
                  propose(next);
                  respondq("@what", type, name);
                  return;
               }
      }
      // final preference is to choose decomposition
      for (Plan next : live ) {
         if ( !next.isDecomposed() ) {
            Task task = next.getGoal();
            List<DecompositionClass> decomps = task.getDecompositions();
            if ( !decomps.isEmpty() ) {
               propose(next);
               // if only one applicable choice, make it without asking user
               if ( decomps.size() == 1 ) {
                  next.apply(decomps.get(0));
                  next(null); // note recursive call to next method
               } else respondq("@how", task);
               return;
            }
         }
      }
      // otherwise just reiterate current focus, if any
      Plan focus = engine.getFocus();
      if ( focus != null && focus.hasLiveDescendants() ) {
         respond("@lets", focus);
         propose(focus);
      } else {
         respond(focus == null ? "@nofocus" : "@nonext");
         propose(null); // clear proposals, if any
      }
   }

   /**
    * Accept system proposals.
    */
   public void yes (String ignore) {
      if ( proposed != null ) {
         accepted = proposed;
         proposed = null;
         rejected = null;
         respond("@ok");
      }
   }

   /**
    * Reject system proposals.
    */
   public void no (String ignore) {
      if ( proposed != null ) {
         rejected = proposed;
         proposed = null;
         accepted = null;
         respond("@ok");
         setFocus(engine.newFocus(rejected));
      }
   }
   
   /**
    * Evaluate given JavaScript code and report return value.
    */
   public void eval (String script) throws ScriptException {
      println("# Returned '"+engine.evalGlobal(script, "Guide")+"'");
   }
   
   /**
    * Start reading user input from given file.
    */
   public void source (String filename) {
      if ( file != null ) 
         warning("Ignoring recursive source "+filename);
      else try {
         file = new File(filename);
         input = new BufferedReader(new FileReader(file));
      } catch (FileNotFoundException e) {
         exception(e);
         file = null;
      }
   }
   
   private Plan lastFocus;
   private final String note = "<-focus";
  
   /**
    * Print out current task decomposition tree with status information.
    */
   public void status (String ignore) {
      if ( engine.getFocus() != lastFocus ) {
         // manage focus note
         if ( lastFocus != null ) lastFocus.getNotes().remove(note);
         lastFocus = engine.getFocus();
         if ( lastFocus != null ) lastFocus.getNotes().add(note);
      }
      Iterator<Plan> tops = engine.tops();
      if ( tops.hasNext() ) {
         System.out.println();
         while (tops.hasNext())
            tops.next().print(System.out);
         System.out.println();
      } else respond("@empty");
   }
   
   /**
    * Reinitialize current task state (but keeps loaded models).
    */
   public void clear (String ignore) {
      engine.clear();
   }
   
   /**
    * Control verbose output.
    * 
    * @param state - "true" or "false"
    */
   public void verbose (String state) {
      TaskEngine.VERBOSE = state.length() > 0 ? Boolean.parseBoolean(state) : true;
   }
   
   /**
    * Control debugging output.
    * 
    * @param state - "true" or "false"
    */
   public void debug (String state) {
      TaskEngine.DEBUG = state.length() > 0 ? Boolean.parseBoolean(state) : true;
   }
   
   // for logging
   private static class CopyOutputStream extends FilterOutputStream {
      private final OutputStream copy;
      public CopyOutputStream (OutputStream stream, OutputStream copy) {
         super(stream); this.copy = copy;
      }
      @Override
      public void write (int oneByte) throws IOException {
         out.write(oneByte); copy.write(oneByte);
      }
      @Override
      public void write (byte[] buffer) throws IOException {
         out.write(buffer); copy.write(buffer);
      }
      @Override
      public void write (byte[] buffer, int offset, int count) throws IOException {
         out.write(buffer, offset, count); copy.write(buffer, offset, count);
      }
      @Override
      public void flush () throws IOException {
         out.flush(); copy.flush();
      }
      @Override
      public void close () throws IOException {
         copy.close(); super.close();
      }
   }
}

