/* 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;

import org.w3c.dom.*;

import java.io.IOException;
import java.util.*;

import javax.imageio.metadata.IIOMetadataNode;
import javax.script.*;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.XPathConstants;

public class TaskEngine {
   
   public static String VERSION = "0.9.4 beta";
   
   public static boolean VERBOSE, DEBUG, COMPILABLE, SCRIPTABLE, INVOCABLE;
   public static Object NOT_FOUND, UNDEFINED;
   
   private String platform, deviceType;
   
   public String getDeviceType (Task task) { return deviceType; }
   public String getPlatform (Task task) { return platform; }

   /**
    * Create a new task engine with given default values for platform and
    * deviceType. Note that how platforms and deviceTypes are associated with
    * tasks is outside the scope of the standard, and is not addressed by
    * this implementation.
    */
   public TaskEngine (String platform, String deviceType) {
      this();
      this.platform = platform;
      this.deviceType = deviceType;
   }
   
   private final ScriptEngine scriptEngine = 
      new ScriptEngineManager().getEngineByName("ECMAScript");
   
   public ScriptEngine getScriptEngine () { return scriptEngine; }
   
   public TaskEngine () {
      if ( scriptEngine == null )
         throw new IllegalStateException("No ECMAScript engine found!");
      // check implementation-dependent properties of script engine
      COMPILABLE = scriptEngine instanceof Compilable;
      INVOCABLE = scriptEngine instanceof Invocable;
      // note lib/js.jar contains classes for compilation on either platform,
      // but not execution classes
      try { // Windows
         UNDEFINED = sun.org.mozilla.javascript.internal.Context.getUndefinedValue();
         NOT_FOUND = sun.org.mozilla.javascript.internal.Scriptable.NOT_FOUND;
         SCRIPTABLE = Sun = true; 
      } catch (Throwable e) {
        try { // Linux, Mac
           UNDEFINED = org.mozilla.javascript.Context.getUndefinedValue();
           NOT_FOUND = org.mozilla.javascript.Scriptable.NOT_FOUND;
           SCRIPTABLE = true; Sun = false;
        } catch (Throwable f) {}
      } 
   }

   private static boolean Sun; // Windows
   
   static Object get (Object object, String field) {
      if ( Sun ) {
         sun.org.mozilla.javascript.internal.Scriptable scriptable = 
            (sun.org.mozilla.javascript.internal.Scriptable) object;
         return scriptable.get(field, scriptable);
      } else {
         org.mozilla.javascript.Scriptable scriptable = 
            (org.mozilla.javascript.Scriptable) object;
         return scriptable.get(field, scriptable);
      }
   }
         
   static void put (Object object, String field, Object value) {
      if ( Sun ) {
         sun.org.mozilla.javascript.internal.Scriptable scriptable = 
            (sun.org.mozilla.javascript.internal.Scriptable) object;
         scriptable.put(field, scriptable, value);
      } else {
         org.mozilla.javascript.Scriptable scriptable = 
            (org.mozilla.javascript.Scriptable) object;
         scriptable.put(field, scriptable, value);
      }
   }
   
   static boolean isScriptable (Object object) {
      return Sun ? 
         object instanceof sun.org.mozilla.javascript.internal.Scriptable :
         object instanceof org.mozilla.javascript.Scriptable;
   }
   
   private long tick = 0;
   
   public long getTick () { return tick; }
   
   /**
    * This method <em>must</em> be called at appropriate times to clear
    * liveness cache. 
    */
   public void tick () { 
      if ( tick == Long.MAX_VALUE ) {
         System.err.println("Warning: wrapping tick back to zero.");
         tick = 0;
      } else tick++; 
      clearLive(); 
   }
   
   /* DESIGN NOTE: We are using the GLOBAL_SCOPE here for the init scripts
    * which is not so great, since it affects all script engines from the
    * same factory.  This is convenient, however, because the 'bindings'
    * arguments temporarily rebind the ENGINE_SCOPE with the instance information.
    */
   
   public Object eval (String script, String where) {
      return eval(script, scriptEngine.getBindings(ScriptContext.GLOBAL_SCOPE), 
                 where);
   }

   public Object eval (String script, Bindings bindings, String where) {
      try {
         bindings.put(ScriptEngine.FILENAME, where);
         return scriptEngine.eval(script, bindings);
      } 
      catch (ScriptException e) { throw new RuntimeException(e); }
      finally { bindings.remove(ScriptEngine.FILENAME); }
   }
   
   public CompiledScript compile (String script, String where) {
      if ( !COMPILABLE || script.length() == 0 ) return null;
      Bindings bindings = scriptEngine.getBindings(ScriptContext.GLOBAL_SCOPE);
      try { 
         bindings.put(ScriptEngine.FILENAME, where);
         return ((Compilable) scriptEngine).compile(script); 
      }
      catch (ScriptException e) { throw new RuntimeException(e); }
      finally { bindings.remove(ScriptEngine.FILENAME); } 
   }
  
   private final Map<String,TaskModel> models = new HashMap<String,TaskModel>(); 
   
   // default properties for all task models
   final protected Properties properties = new Properties();
   { try {
      properties.load(TaskEngine.class.getResource("default.properties").openStream());
     } catch (IOException e) { throw new RuntimeException(e); }
   }
   
   public String getProperty (String key) { 
      return properties.getProperty(key);
   }
   
   /**
    * Load and return task model from specifiedXML document.  Loads the
    * first toplevel <taskModel> element.
    * 
    * <em>NB:</em> This implementation does not support forward or circular 
    * namespace references using prefixes.  Thus task models must be loaded
    * in order such that all external references to task model namespaces refer 
    * to already loaded models. 
    *  
    * @param from string identifying url, system resource or file containing XML document
    */
   public TaskModel parse (String from) {
      try { 
         NodeList nodes = DocumentBuilderFactory.newInstance().newDocumentBuilder()
                            .parse(Utils.toURL(from).openStream()).getChildNodes();
         TaskModel model = null;;
         // use first task model 
         for (int i = 0; i < nodes.getLength(); i++) {
            Node node = nodes.item(i);
            if ( "taskModel".equals(node.getNodeName()) ) {
               model = new TaskModel(node, this);
               break;
            }
         }
         if ( model == null ) 
            throw new RuntimeException(from+" does not contain a task model");
         models.put(model.getNamespace(), model);
         // TODO internationalize with resource bundles
         String props = Utils.replaceEndsWith(from, ".xml", ".properties");
         try {
            properties.load(Utils.toURL(props).openStream());
            if ( VERBOSE ) System.out.println("    # Loaded "+props);
         } catch (IOException e) {} // ignore if no properties file
         // evaluate initialization script(s)
         for (Script script : model.getScripts())
            if ( script.isInit() ) eval(script.getText(), model+" init");
         clearLive();
         if ( callback != null ) callback.onParse(from, model);
         return model;
      } catch (Exception e) { throw new RuntimeException(e); }
   }
   
   /**
    * Interface for {@link #setOnParsing(OnParsing)} callback.
    */
   public interface OnParsing {
      void onParse (String uri, TaskModel model);
   }
   
   private OnParsing callback = null;
   
   /**
    * Set object to be called after new task model loaded.
    */
   public void setOnParsing (OnParsing callback) { this.callback = callback; }
   
   /**
    * Return task model for given namespace. If such a model has
    * not already been loaded, resolve namespace  and load.
    */
   public TaskModel getModel (String namespace) {
      TaskModel model = models.get(namespace);
      if ( model != null ) return model;
      return parse(namespace); // resolve
   }
 
   /**
    * Return the unique task class, if any, in currently loaded task models
    * which is identified by given id.
    *  
    * @param id
    * @return task or decomposition class or null if none
    * @throws AmbiguousIdException
    */
   public TaskClass getTaskClass (String id) {
      TaskClass found = null;
      for (TaskModel model : models.values()) {
         TaskClass task = model.getTaskClass(id);
         if ( task != null ) {
            if ( found == null ) found = task;
            else throw new AmbiguousIdException(id+" found in "
                  +task.getNamespace()+" and "+found.getNamespace());
         }
      }
      return found;
   }
   
   public static class AmbiguousIdException extends RuntimeException {
      public AmbiguousIdException (String message) { super(message); }
   }
   
   private final Map<Node,Description> nodeCache = new HashMap<Node,Description>();
   
   Description get (Node node) { return nodeCache.get(node); }
   
   void put (Node node, Description description ) {
      nodeCache.put(node, description);
   }
   
   List<Node> xpathAll (String expression) {
      List<Node> nodes = new ArrayList<Node>();
      try { 
         for (TaskModel model : models.values()) {
            NodeList found =  (NodeList) Description.XPATH
              .evaluate(expression, model.node, XPathConstants.NODESET);
            for (int i = 0; i < found.getLength(); i++) // preserve order
               nodes.add(found.item(i));
         }
         return nodes;
      } catch (Exception e) { throw new RuntimeException(e); }
   }
  
   private long start; 
   
   /**
    * @see #setStart(long)
    */
   public long getStart () { return start; }
   
   /**
    * Set the start time to be used for 'when' slot in verbose version of
    * {@link Task#toString()}.  If start time is greater than zero, then shows 
    * 'when' in seconds since start time, rather than UTC in milliseconds.
    * 
    * @param start - start time in milliseconds (e.g., {@link System#currentTimeMillis()})
    * 
    * @see #VERBOSE
    */
   public void setStart (long start) { this.start = start; }
   
   // basic task tree management
   
   private Plan root;
   { clear(); }
   
   public void clear () {
      // TODO find better dummy Node type?
      Node node = new IIOMetadataNode("Root");
      root = new Plan(new TaskClass(node, new TaskModel(node, this)).newInstance()); 
      focus = null;
      if ( cacheLive != null ) clearLive(); // called from constructor
   }
   
  public Iterator<Plan> tops () { return root.children(); }
   
   /**
    * Add given plan to current task tree at toplevel.   Shifts the focus
    * to this plan if it is not done.
    */
   public void addTop (Plan plan) { 
      root.add(plan);
      if ( focus == null && !plan.isDone() ) focus = plan;
   }
   
   /**
    * Remove given toplevel plan from current tree. Also sets focus to null, if
    * given plan was focus.
    */
   public void removeTop (Plan plan) { 
      root.remove(plan);
      if ( focus == plan ) focus = null;
   }
      
   public Plan getTop (Plan plan) {
      Plan parent = plan.getParent();
      return parent == root ? plan : parent == null ? null : getTop(parent);
   }

   /**
    * Call {@link Plan#decomposeAll()} on all plans in current tree.
    */
   public void decomposeAll() {
      for (Iterator<Plan> tops = tops(); tops.hasNext();) {
         Plan top = tops.next();
         if ( !top.isDone() ) top.decomposeAll();
      }
   }
   
   // cache liveness calculation to speed up
   
   private final Map<Plan, Boolean> cacheLive = new IdentityHashMap<Plan, Boolean>();
    
   public Boolean isLive (Plan plan) { return cacheLive.get(plan); }
   public void setLive (Plan plan, boolean live) { cacheLive.put(plan, live); }
   public void clearLive () { cacheLive.clear(); }
   
   /**
    * Method for notifying task engine that given primitive task has occurred.
    * 
    * @return matching plan in current task tree or null if none
    */
   public Plan done (Task occurrence) {
      // leave "success" undefined (see doc for Task.isDefinedOutputs)
      occurrence.setExternal(true); 
      occurrence.setWhen(System.currentTimeMillis());
      Plan match = findMatch(occurrence, true);
      if ( match != null ) match.recognize(occurrence);
      return match;
   }
 
   // simple plan recognition (without decomposition choice interpolation)
   
   /**
    * Look for plan in task tree matching given task. 
    * 
    * @return matching plan or null, if none
    * 
    * @see #findMatches(Task,boolean)
    */
   public Plan findMatch (Task task, boolean onlyLive) {
      List<Plan> matches = findMatches(task, onlyLive);
      return matches.isEmpty() ? null : resolveAmbiguity(matches);
   }
   
   /**
    * Return list of plans in task tree with goal matching given task, searching
    * in the following categories, in order:
    * <ol>
    * <li> within current focus
    * <li> within current toplevel plan
    * <li> any toplevel plan
    * </ol>
    * If matching plans are found in one category, search does <em>not</em> continue to next category.
    * 
    * @param onlyLive only search live plans
    */
   public List<Plan> findMatches (Task task, boolean onlyLive) {
      Plan focus = getFocus();  // shadow for override in Disco
      if ( focus != null ) {
         List<Plan> matches = focus.findMatches(task, onlyLive);
         if ( !matches.isEmpty() ) return matches;
         matches = getTop(focus).findMatches(task, onlyLive);
         if ( !matches.isEmpty() ) return matches;
      }
      return root.findMatches(task, onlyLive);
   }
   
   private Plan resolveAmbiguity (List<Plan> matches) {
      if ( matches.size() > 1 ) {
         String name = null;
         for (Plan plan : matches) {
            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) {
      System.err.println("Ignoring ambiguity of "+matches.size());
      if ( DEBUG ) System.err.println(matches);      
   }
   
   // simple focus system
   
   private Plan focus;
   
   /**
    * Return the current focus in the task tree, or null if no focus.<br>
    * <br>
    * Note that this is <em>not</em> the discourse concept of focus (cf.
    * Collagen), but rather a much simpler "execution" focus. For example, there
    * is no focus stack. Also, the focus will not usually not be on a done plan.
    * Management of the focus is the responsibility of the application using the
    * engine (e.g., the Guide), with the exception of {@link #addTop(Plan)} and
    * {@link #removeTop(Plan)}, as documented.<br>
    * <br>
    * Note also that this is not the same as the "next" live action, since in a
    * partially ordered plan, there may be several next actions to choose from.
    * The current focus will usually have the next live actions as its children.
    * 
    * @see #setFocus(Plan)
    */
   public Plan getFocus () { return focus; }
   
   /**
    * Shift focus to the given plan or null.
    * 
    * @return true iff this is an "unexpected focus shift" in the sense of Lesh,
    *         Rich & Sidner's UM'01 paper, i.e., we are leaving the current
    *         focus when it is started but not done.
    *         
    * @see #getFocus()        
    */
   public boolean setFocus (Plan focus) {
      if ( focus == this.focus ) return false;
      if ( focus != null && (focus == root || !focus.isAncestor(root)) )
         throw new IllegalArgumentException(focus+" is not in task tree!");
      Plan old = this.focus;
      this.focus = focus;
      return old != null 
         && old.isStarted() && !old.isDone()
         && (focus == null || !( old.isAncestor(focus) || focus.isAncestor(old)) ); 
   }
  
   /**
    * Return first ancestor of given plan which is live or has live descendants,
    * stopping at toplevel plan containing given plan, or null if none.
    */
   public Plan newFocus (Plan plan) {
      Plan parent = plan.getParent();
      // TODO optimize this code?
      return parent == null || parent == root ? null :
            (parent.isLive() || parent.hasLiveDescendants()) ? parent : 
               newFocus(parent);
   }
 
}
