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

import java.text.DateFormat;
import java.util.*;
import javax.script.*;

/**
 * Representation for instances of a task class.
 */
public class Task extends Instance {
   
   Task (TaskClass type, TaskEngine engine) { 
      super(type, engine); 
      try { 
         // create JavaScript instance object (no initial slot properties)
         // do not use Instance.eval below because Decomposition.Step includes
         // call to updateBindings, but during constructor, steps are not yet added
         engine.eval("$this = { model: \""+type.getNamespace()+"\"," +
         		                " task: \""+type.getId()+"\"  }",
                bindings, "new Task");
      } catch (Exception e) { throw new RuntimeException(e); }
   }
   
   @Override
   public TaskClass getType () { return (TaskClass) super.getType(); }
   
   /**
    * Return value of the named slot.<br>
    * <br>
    * Note that this method returns a Java object, but the <em>real</em>
    * values are stored in JavaScript and must be converted to Java objects when
    * returned by this method. Refer to Chapter 12 of O'Reilly JavaScript guide
    * for details of conversion, but note following subtleties here:
    * <ul>
    * <li> JavaScript objects and arrays (i.e., everything except for numbers,
    * strings and booleans) convert to Java objects of "opaque" type. These
    * values can be passed back to JavaScript, but have an unpublished API that
    * is not intended for use by Java programs.
    * <li> <em>Both</em> null and undefined in JavaScript convert to null
    * </ul>
    * Since there is no attribute for "optional" slots in CEA-2018, all input
    * slot values must be defined before executing a task and all output slots
    * will be defined after execution. However, a null value can be used unless
    * a pre- or postcondition specifies otherwise.
    * 
    * @see #isDefinedSlot(String)
    * @see #deleteSlotValue(String)
    * @see #isDefinedInputs()
    * @see #isDefinedOutputs()
    */
   public Object getSlotValue (String name) { 
      if ( TaskEngine.SCRIPTABLE ) {
         Object value = TaskEngine.get(bindings.get("$this"), name);
         return isDefined(value) ? value : null;
      } else return eval("$this."+name, "getSlotValue"); 
   }
   
   /**
    * Return the string obtained by calling <em>JavaScript</em> toString method 
    * on value of named slot.   Good for debugging output.
    */
   public String getSlotValueToString (String name) {
      if ( TaskEngine.INVOCABLE )
         try { return (String) ((Invocable) engine.getScriptEngine())
                               .invokeMethod(getSlotValue(name), "toString"); }
         catch (ScriptException e) { throw new RuntimeException(e); }
         catch (NoSuchMethodException e) { throw new RuntimeException(e); }
      else { return (String) 
                eval("$this."+name+".toString()", "getSlotValueToString"); } 
   }
   
   /**
    * Test whether slot value is defined. Note if getSlotValue() returns null,
    * isDefinedSlot() may return true <em>or</em> false.
    * 
    * @see #getSlotValue(String)
    * @see #deleteSlotValue(String)
    */
   public boolean isDefinedSlot (String name) {
      return TaskEngine.SCRIPTABLE ?
         isDefined(TaskEngine.get(bindings.get("$this"), name)) 
         // note using === comparison below b/c null==undefined in JavaScript
         : evalCondition("$this."+name+" !== undefined", "isDefinedSlot");
   }
   
   private static boolean isDefined (Object value) {
      return !( value == TaskEngine.NOT_FOUND || value == TaskEngine.UNDEFINED );
   }
   
   // see Decomposition
   private boolean modified = true;
   boolean isModified () { return modified; }
   void setModified (boolean modified) { this.modified = modified; }
   
   public void setSlotValue (String name, Object value) {
      setSlotValue(name, value, true);
   }
      
   /**
    * Set the value of named slot to given value.<br>
    * <br>
    * Note that the given Java object must be converted to a JavaScript object
    * for storage. Refer to Chapter 12 of O'Reilly JavaScript guide for details
    * of conversion, but note following subtleties here:
    * <ul>
    * <li> Java objects and arrays (i.e., everything but numbers, booleans and 
    * strings) convert to instances of JavaObject and JavaArray 
    * </ul>
    * 
    * @see #getSlotValue(String)
    * @see #setSlotValueScript(String,String,String)
    */
   public void setSlotValue (String name, Object value, boolean check) {
      String type = getType().getSlotType(name);
      if ( !check || checkSlotValue(type, value) ) {
         if ( TaskEngine.SCRIPTABLE && 
               ( value == null || value instanceof Boolean 
                 || value instanceof String || value instanceof Number 
                 || TaskEngine.isScriptable(value) ||
                 value == TaskEngine.UNDEFINED ) ) { 
            modified = true;
            TaskEngine.put(bindings.get("$this"), name, value);
         } else try {
            bindings.put("$$value", value); // convert to JavaScript value
            modified = true;
            eval("$this."+name+" = $$value;", "setSlotValue"); 
         } finally { bindings.remove("$$value"); }
      } else failCheck(name, value.toString(), "setSlotValue");
   }  
     
   public boolean checkSlotValue (String type, Object value) {
      if ( value == null ||
           ( type.equals("boolean") && value instanceof Boolean ) ||
           ( type.equals("string") && value instanceof String ) ||
           ( type.equals("number") && value instanceof Number ) )
         return true;
      try {
         bindings.put("$$value", value); // convert to JavaScript object
         return (Boolean) 
            eval( // workaround bug: "$$value instanceof Date" returns false
                  type.equals("Date") ? "$$value.getUTCDate !== undefined" :
                  ("$$value instanceof "+type),
                  "checkSlotValue");
      } finally { bindings.remove("$$value"); }
   }
 
   /**
    * Set the value of named slot to result of evaluating given JavaScript
    * expression. This method avoids issues of conversion from Java to
    * JavaScript objects.
    * 
    * @see #setSlotValue(String,Object)
    */
   public void setSlotValueScript (String name, String expression, String where) {
      // only evaluate expression once (in case of side effects)
      try {
         if ( !evalCondition(makeExpression("$this", getType(), name, expression), where) )
            failCheck(name,expression, where);
         else modified = true;
      } finally { bindings.remove("$$value"); }
   }

   static String makeExpression (String self, TaskClass type, String name, String expression) {
      return  
      "$$value = "+expression+';'+
      "if ( "+checkExpression(type, name)+" ) {"+
         self+'.'+name+" = $$value; $$value = true; "+
      "} else $$value = false; $$value";
   }
   
   private void failCheck (String name, String expression, String where) {
      throw new IllegalArgumentException(
            "Type error: Cannot assign result of "+expression+" to "
            +getType().getSlotType(name)+" slot "+name
            +" of "+getType()+" in "+where);
   }
   
   public boolean checkSlotValueScript (String name, String expression) {
      // only evaluate expression once (in case of side effects)
      try { return evalCondition(
               "$$value = "+expression+';'+checkExpression(getType(), name), 
               "checkSlotValueScript");
      } finally { bindings.remove("$$value"); }
   }

   private static String checkExpression (TaskClass task, String name) {
      String type = task.getSlotType(name);
      return "$$value === undefined || "+ // allow any slot to be set to undefined
      (type.equals("boolean") ? "typeof $$value == \"boolean\"" :
         type.equals("string") ? "typeof $$value == \"string\"" :
            type.equals("number") ? "typeof $$value == \"number\"" :
               // work around bug: "$$value instanceof Date" returns false
               type.equals("Date") ? "$$value.getUTCDate !== undefined" :
                  "$$value instanceof "+type);
   }
   
   /**
    * Like {@link #setSlotValueScript(String,String,String)} but with extra 
    * bindings to use for evaluation of expression (instead of task bindings)
    */
   void setSlotValueScript (String name, String expression, String where,
                            Bindings extra) {
      try {
         extra.put("$$this", bindings.get("$this"));
         if ( !evalCondition(makeExpression("$$this", getType(), name, expression), 
                             extra, where) )
            failCheck(name, expression, where);
         else  modified = true;
      } 
      finally { extra.remove("$$value"); extra.remove("$$this"); }
   }
   
   void setSlotValueScript (String name, CompiledScript compiled, String where,
                            Bindings extra) {
      try {
         extra.put("$$this", bindings.get("$this"));
         if ( !evalCondition(compiled, extra, where) )
            failCheck(name, "compiled script", where);
         else  modified = true;

      } 
      finally { extra.remove("$$value"); extra.remove("$$this"); }
   }
   
   /**
    * Make named slot undefined.
    * 
    * @see #isDefinedSlot(String)
    */
   public void deleteSlotValue (String name) {
      eval("delete $this."+name, "deleteSlotValue");
      modified = true;
   }
   
   public Boolean isApplicable () {
      // empty string evaluates to null
      TaskClass type = getType();
      return evalCondition(type.precondition, type.compiledPrecondition, 
            type+" precondition");
   }
   
   public Boolean isAchieved () {
      // empty string evaluates to null
      TaskClass type = getType();
      return evalCondition(type.postcondition, type.compiledPostcondition, 
            type+" postcondition");
   }

   /**
    * Test whether all declared input slots (i.e., not including
    * "external") have defined values.
    * 
    * @see #getSlotValue(String)
    */
   public boolean isDefinedInputs() {
      for (String name : getType().getDeclaredInputNames())
         if ( !isDefinedSlot(name) ) return false;
      return true;
   }

   /**
    * Test whether all declared output slots (i.e., not including "success" and
    * "when") have defined values. 
    * 
    * @see #getSlotValue(String)
    */
   public boolean isDefinedOutputs() {
      for (String name : getType().getDeclaredOutputNames())
         if ( !isDefinedSlot(name) ) return false;
      return true;
   }
   
   // predefined slots
   
   /**
    * Get time when this task occurred, if it did.
    *
    * @return milliseconds or 0 (if not occurred)
    * @see #setWhen(long)
    */
   public long getWhen () {
      try { 
         return ((Double) // for some reason, millis get converted to double first 
               engine.eval("$this.when.getTime()", bindings, "getWhen")).longValue(); 
      } catch (Exception e) { throw new RuntimeException(e); }
   }

   /**
    * Set the time when this task occurred. Javascript instance of Date object
    * is created with given milliseconds.
    * 
    * @see #getWhen()
    */
   public void setWhen (long milliseconds) {
      setSlotValueScript("when", "new Date("+Long.toString(milliseconds)+")", 
            "setWhen");
   }
   
   /**
    * Test whether this task instance occurred.
    */
   public boolean occurred () { return isDefinedSlot("when"); }
      
   /**
    * Get the value of the predefined 'success' slot 
    * @return TRUE, FALSE or null
    */
   public Boolean getSuccess () {
      return (Boolean) getSlotValue("success");
   }

   /**
    * Set the predefined 'success' output slot of this task.
    * 
    * @param success - note type boolean so converts to JavaScript boolean
    */
   public void setSuccess (boolean success) {
      setSlotValue("success", success);
   }
   
   /**
    * Get the value of the predefined 'external' slot.
    *  
    * @return TRUE, FALSE or null
    */
   public Boolean getExternal () {
      return (Boolean) getSlotValue("external"); 
   }
   
   /**
    * Set the predefined 'success' output slot of this task.
    * 
    * @param external - note type boolean so converts to JavaScript boolean
    */
   public void setExternal (boolean external) {
      setSlotValue("external", external);
   }

   public List<DecompositionClass> getDecompositions () {
      List<DecompositionClass> decomps = getType().getDecompositions();
      for (Iterator<DecompositionClass> i = decomps.iterator(); i.hasNext();)
         // remove decompositions not applicable to this task instance
         if ( Utils.isFalse(i.next().isApplicable(this)) )      
            i.remove();
      return decomps; 
   }

   /**
    * Test whether given task instance matches this task instance. Two task
    * instances match (symmetric relationship) iff they are both instances of the
    * same type and the values of each corresponding slot are either equal or
    * one is undefined.
    * 
    * @see #copySlotValues(Task)
    */
   public boolean matches (Task task) {
      if ( task == this ) return true;
      if ( !getType().equals(task.getType()) ) return false;
      for (String name : getType().getInputNames())
         if ( !matchesSlot(task, name) ) return false;
      for (String name : getType().getOutputNames())
         if ( !matchesSlot(task, name) ) return false;
      return true;
   }
   
   private boolean matchesSlot (Task task, String name) {
      return !isDefinedSlot(name) || !task.isDefinedSlot(name) 
        || Utils.equals(getSlotValue(name), task.getSlotValue(name));
   }

   /**
    * Copies the values of defined slots <em>from</em> given task to this task. Note this
    * side-effects the given task and will overwrite existing slot values.
    * 
    * @param from - task of same type
    * @return true if any slots of given task overwritten (were defined)
    * 
    * @see #matches(Task)
    */
   public boolean copySlotValues (Task from) {
      boolean overwrite = false;
      for (String name : getType().getInputNames())
         overwrite = copySlotValue(from, name) || overwrite;
      for (String name : getType().getOutputNames())
         overwrite = copySlotValue(from, name) || overwrite;
      return overwrite;
   }
   
   /**
    * If named slot of this task is defined, copy value <em>from</em> 
    * slot name of given task to this task.
    *
    * @param from - task of same type
    * @return true if slot of given task is overwritten (was defined)
    */
   public boolean copySlotValue (Task from, String name) {
      return copySlotValue(from, name, name, true);
   }
   
   public boolean copySlotValue (Task from, String fromSlot, String thisSlot, 
                                 boolean onlyDefined) {
      if ( TaskEngine.SCRIPTABLE ) {
         Object value = TaskEngine.get(from.bindings.get("$this"), fromSlot);
         if ( onlyDefined && !isDefined(value) ) return false;
         Object task = bindings.get("$this");
         Object thisValue = TaskEngine.get(task, thisSlot);
         boolean overwrite = isDefined(thisValue);
         TaskEngine.put(task,thisSlot, value);
         modified = true;
         return overwrite;
      } else {
         if ( onlyDefined && !from.isDefinedSlot(fromSlot) ) return false;
         boolean overwrite = isDefinedSlot(thisSlot);
         setSlotValueScript(thisSlot, "$this."+fromSlot, "copySlotValue", 
               from.bindings);
         return overwrite;
      }
   }

   public void execute () {
      getScript().eval(this); 
      setWhen(System.currentTimeMillis());
      setExternal(false);
      engine.clearLive();
   }
   
   /**
    * Return appropriate and applicable script to ground this task, or null if none.
    */
   public Script getScript () {
      TaskClass type = getType();
      List<Description.Taskable> candidates = 
         new ArrayList<Description.Taskable>(type.scripts);
      for (Iterator<Description.Taskable> i = candidates.iterator(); i.hasNext();) {
         Description.Taskable script = i.next();
         String platform = script.getPlatform(),
            deviceType = script.getDeviceType();
         // remove scripts not appropriate to current platform and deviceType
         if ( !( (platform == null || platform.equals(engine.getPlatform(this)))
                 && (deviceType == null || deviceType.equals(engine.getDeviceType(this))) ) )
            i.remove();
      }
      if ( candidates.isEmpty() ) { 
         // look for default script for model and then overall (must be toplevel)
         List<Node> nodes = engine.xpathAll("./script");
         Script global = null;
         for (Node node : nodes) {
            Script script = type.getScript(node, null);
            if ( script.isInit() ) continue;
            TaskClass task = script.getTask();
            if ( task == null ) {
               String model = script.getModel();
               // not checking for multiple global scripts
               if ( model == null ) global = script; 
               else if ( model.equals(type.getNamespace()) )
                  candidates.add(script);
            } else if ( task.equals(type) ) candidates.add(script);
         }
         if ( candidates.isEmpty() && global != null )
            candidates.add(global);
      }
      if ( candidates.isEmpty() ) return null;
      if ( candidates.size() > 1 ) 
         System.out.println("Ignoring multiple scripts for "+this);
      Script script = (Script) candidates.get(0);
      return Utils.isFalse(script.isApplicable(this)) ? null : script;
   }
   
   @Override
   protected String toStringVerbose () { 
      StringBuffer buffer = new StringBuffer(super.toStringVerbose()).append("={ ");
      boolean first = true;
      for (String name : getType().getInputNames()) 
         first = appendSlot(buffer, name, first);
      for (String name : getType().getOutputNames()) 
         if ( !name.equals("when") ) first = appendSlot(buffer, name, first);
      // handle 'when' specially (but not 'success')
      if ( TaskEngine.DEBUG && isDefinedSlot("when") ) {
         long start = engine.getStart();
         long when = getWhen();
         first = appendSlot(buffer, "when", 
               start == 0 ? 
                  DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
                     .format(new Date(when)) : 
                  Long.toString((when-start)/100), // tenth second accuracy enough
               first);
      }
      return buffer.append(" }").toString();
   }

   private boolean appendSlot (StringBuffer buffer, String name, boolean first) {
      return isDefinedSlot(name) ? 
         appendSlot(buffer, name, getSlotValueToString(name), first) :
            first;
   }
   
   private boolean appendSlot (StringBuffer buffer, String name, String value, 
         boolean first) {
      if ( !first ) buffer.append(", "); else first = false;
      buffer.append(name).append(':').append(value);
      return first;
   }

   public String format () { 
      String format = getProperty("@format");
      if ( format == null ) return toString();
      List<Object> slots = new ArrayList<Object>();
      for (String name : getType().getDeclaredInputNames())
         slots.add(formatSlot(name));
      for (String name : getType().getDeclaredOutputNames())
         slots.add(formatSlot(name));
      return String.format(format, slots.toArray());
   }
   
   private String formatSlot (String name) {
      if ( isDefinedSlot(name) ) return getSlotValueToString(name);
      String property = getProperty('.'+name+"@indefinite");
      return property != null ? property :
         (engine.getProperty("@indefinite")+' '
               +Utils.decapitalize(getType().getSlotType(name)));
   }
   
   public String getProperty (String key) { return getType().getProperty(key); }
}