/* 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 edu.wpi.cetask.*;

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

public class Guide {

   public static String VERSION = "0.9.3 beta";
   public String response;
   
   public static void main (String[] args) {
      make(args).loop();
   }
   
   public static Guide make (String[] args) {  // for use in other mains
      // for convenience to start both Guide and SpeechGuide from same jar file
      return args.length > 0 && args[0].equals("-speech") ?
         SpeechGuide.make(Utils.restArgs(args)) : 
         new Guide(new TaskEngine(), args.length > 0 ? args[0] : null);
   }
   
   final protected TaskEngine engine;
   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 URL source;   // current alternative input source (or null)
   
   /**
    * @param from if non-null, change default name of log file to correspond
    *        to name of given source and immediately start reading from that source
    * 
    * @see #source(String)
    */
   protected Guide (TaskEngine engine, String from) {
	  this.engine = engine;
	  if ( from == null ) input = systemIn();
      else source(from);
      log = new File(System.getProperty("java.io.tmpdir") + '/'
         + (source == null ? 
            "console" : 
            new File(source.getFile()).getName()) 
         + ".guide");
   }
   
   protected void printVersion () {
      System.out.print(" - Engine "+TaskEngine.VERSION+" / Guide "+VERSION);
   }
   
   /**
    * Command loop. Uses reflection to invoke commands; see methods with command
    * names.
    */
   public void loop () {
      PrintStream logStream = null;
      try {
         logStream = new PrintStream(new BufferedOutputStream(
               new FileOutputStream(log)), true);
         if ( source == null ) {
            println("# Writing console log to " +log+ "...");
            copyOutput(logStream);
         } else { System.setOut(logStream); System.setErr(logStream); }
         long now = System.currentTimeMillis();
         print("# "+DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
               .format(new Date(now)));
         printVersion(); System.out.println();
         engine.setStart(now);
         respond("@welcome");
         if ( !TaskEngine.COMPILABLE )
            println("Warning: "+engine.getScriptEngine()+
                  " is not Compilable (will run slower).");
         if ( !TaskEngine.SCRIPTABLE)
            println("Warning: "+engine.getScriptEngine()+
                  " does not use (sun.)org.mozilla.javascript.(internal.)Scriptable and will run slower.");
         String prompt = engine.getProperty("@prompt");
         while (true) { 
            System.out.print(prompt);
            String line = input.readLine();
            if ( line == null && source != null ) {
               // switch input back to user
               source = null;
               copyOutput(logStream);
               input = systemIn(); 
               line = input.readLine();
            }
            if ( line == null ) break;
            // for convenience to make source files using keep-lines in Emacs
            line = line.startsWith(prompt) ? line.substring(prompt.length()) :
               line.startsWith("CE> ") ? line.substring(4) : line;
            (source == 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 {};
   
   public void deallocate () {} // for SpeechGuide
   
   private Runnable onProcessCommand;

   /**
    * @param onProcessCommand hook to run after each user command is executed.  
    *        Useful, for example, for updating associated gui
    */
   public void setOnProcessCommand (Runnable onProcessCommand) { 
      this.onProcessCommand= onProcessCommand;
   }
   
   protected 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();
            if ( onProcessCommand != null ) onProcessCommand.run();
         } catch (NoSuchMethodException m) {
            respond("@invalid");
         } catch (InvocationTargetException t) {
            throw t.getTargetException();
         } catch (Exception e) { exception(e); }
         system(); // system turn
      }
   }
   
   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 property = type.getProperty("."+name+"@definite");
      String string = property != null ? property : 
         engine.getProperty("@definite")+' '+type+' '
         +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, TaskEngine.DEBUG ? task : task.format(), question);
   }
   
   private void respond (String key, Object object, boolean question) {
      println(engine.getProperty(key)+' '+object+(question ? '?' : '.'));
      response = engine.getProperty(key)+' '+object+(question ? '?' : '.');
   }
   
   protected void respond (String key) {
      println(engine.getProperty(key));
      response = engine.getProperty(key);
   }

   protected void println (Object object) {
      System.out.print("    ");
      System.out.println(object);
   }
   
   protected void print (Object object) {
      System.out.print("    ");
      System.out.print(object);
   }
   
   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); 
   }
 
   // simple discourse state
   
   protected Plan proposed, accepted, rejected;
   
   protected String command;
   
   private boolean authorized;
   
   /**
    * Check whether this guide is authorized to perform system tasks without asking for
    * permission.  Default false;
    */
   public boolean isAuthorized () { return authorized; }
   
   /**
    * Authorize this guide to perform system tasks without asking for permission.
    */
   public void setAuthorized (boolean authorized) {
      this.authorized = authorized;
   }

   /**
    * 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) ) {
                  Task goal = next.getGoal();
                  // temporary fix for builtin 'Say' task (always authorized)
                  // TODO provide key instead
                  if ( authorized || goal.getType().getId().equals("Say") ) {
                     next.execute();
                     updateFocus();
                  } else {
                     respondq("@shall", goal);
                     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 optional if unambiguous id)");
      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 (XML).
    * 
    * @param from string identifying url, system resource or file
    */
   public void load (String from) {
      // verify model against schema
      if ( Utils.validate(from) ) {
         TaskModel model = engine.parse(from);
         if ( TaskEngine.VERBOSE ) 
            println("# Parsed and validated "+model);
      }
   }
   
   /**
    * Set current task focus. Namespace may be omitted if it is unambiguous
    * in currently loaded task models. 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 ) {
         Plan focus = null;
         Plan match = engine.findMatch(task, true);
         if ( match != null ) {
            match.recognize(task);
            focus = match;
            respond("@ok");
         } else {
            engine.addTop(focus = new Plan(task));
            if ( !task.getType().isPrimitive() ) respond("@top", task);
         }
         // always change focus
         setFocus(focus);
      }
   } 
      
   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); 
   }
   
   protected void done (Task occurrence) {
      if ( proposed != null ) {
         Task task = proposed.getGoal();
         if ( task.matches(occurrence) )
            occurrence.copySlotValues(task);
      }
      if ( occurrence.isDefinedInputs() ) { 
         Plan match = engine.done(occurrence);
         if ( match != null ) {
            if ( !completed() ) respond("@ok");
            setFocus(match);
         } else {
            respond("@unknown");
            Plan plan = new Plan(occurrence),
                 focus = engine.getFocus();
            if ( focus == null ) engine.addTop(plan);
            else focus.add(plan);
         } 
         updateFocus();
         propose(null); // clear proposals, if any
      } else warning("All input values must be defined--ignored."); 
   }
 
   /**
    * 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;
   }
   
   public 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 = null;
         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 = namespace == null ? engine.getTaskClass(id) :
               engine.getModel(namespace).getTaskClass(id);
            if ( type == null ) 
               throw new IllegalArgumentException("Unknown task id "+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 rest of line starting at: "+tokenizer.nextToken());
      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();
      updateFocus();
      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) {
      println("# Returned '"+engine.eval(script, "Guide")+"'");
      engine.clearLive();
   }
   
   /**
    * Start reading user input from url, system resource or file 
    * identified by given string. 
    */
   public void source (String from) {
      if ( source != null ) 
         warning("Ignoring recursive source "+from);
      else try {
         source = Utils.toURL(from);
         input = new BufferedReader(new InputStreamReader(source.openStream()));
      } catch (IOException e) {
         exception(e);
         source = null;
      }
   }
  
   protected final static String FOCUS_NOTE = "<-focus";
  
   /**
    * Print out current task decomposition tree with status information.
    */
   public void status (String ignore) {
      Plan focus = engine.getFocus();
      try {
         if ( focus != null) focus.getNotes().add(FOCUS_NOTE);
         status();
      } finally {
         if ( focus != null) focus.getNotes().remove(FOCUS_NOTE);
      }
   }
   
   protected void status () {
      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();
      }
   }
}

