/* 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) { 
      return eval("$this."+name, "getSlotValue"); 
   }
   
   /**
    * Return the string obtained by calling toString method (<em>in JavaScript</em>)
    * on value of named slot.   Good for debugging output.
    */
   public String getSlotValueToString (String name) { 
      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 evalCondition("$this."+name+" != undefined", "isSlotDefined");
   }
   
   // see Decomposition
   private boolean modified = true;
   boolean isModified () { return modified; }
   void setModified (boolean modified) { this.modified = modified; }
      
   /**
    * 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 and booleans)
    * convert to instances of JavaObject and JavaArray 
    * <li> Specifically, Java strings convert to instances of JavaObject 
    * (not JavaScript strings!)
    * </ul>
    * 
    * @see #getSlotValue(String)
    * @see #setSlotValueScript(String,String,String)
    */
   public void setSlotValue (String name, Object value) {
      modified = true;
      // TODO:  Add type checking for arrays
      String type = getType().getSlotType(name);
      if ( type.equals("boolean") ) {
         if ( value instanceof Boolean ) {
            setBooleanSlotValue(name, (Boolean) value);
            return;
         } else failCheck(name, value, type);
      } else if ( type.equals("string") ) {
         if ( value instanceof String ) {
            setStringSlotValue(name, (String) value);
            return;
         } else failCheck(name, value, type);
      } else if ( type.equals("number") ) {
         if ( value instanceof Integer ) {
            setIntSlotValue(name, (Integer) value);
            return;
         }
         // TODO add other number types (fall through)
      }
      if ( !checkSlotValue(name, value) ) failCheck(name, value, type);
      bindings.put("$$value", value); // convert to JavaScript value
      try { eval("$this"+'.'+name+" = $$value;", "setSlotValue2"); } 
      finally { bindings.remove("$$value"); if (name.equals("when")) getWhen();}
   }
   
   private void failCheck (String name, Object value, String type) {
      throw new IllegalArgumentException("Type error: Cannot assign "
            +value+" to "+type+" slot "+name);
   }
   
   public boolean checkSlotValue (String name, Object value) {
      String type = getType().getSlotType(name);
      if ( type.equals("boolean") ) 
         return value instanceof Boolean;
      else if ( type.equals("string") )
         return value instanceof String;
      else if ( type.equals("number") ) 
         return value instanceof Number;
      else {      
         Bindings temp = new SimpleBindings();
         temp.put("$$value", value); // convert to JavaScript value
         try { return (Boolean) 
            engine.eval(
                  // workaround bug: "$$value instanceof Date" returns false
                  type.equals("Date") ? "$$value.getUTCDate != undefined" :
                  "$$value instanceof "+getType().getSlotType(name), 
              temp, "checkSlotValue");
         } catch (ScriptException e) { throw new RuntimeException(e); }
      }
   }
   
   /**
    * Like setSlotValue(), but results in boolean JavaScript value. Convenience
    * method which calls setSlotValueScript. 
    * 
    * @see #setSlotValue(String,Object)
    * @see #setSlotValueScript(String,String,String)
    */
   public void setBooleanSlotValue (String name, Boolean value) {
      setSlotValueScript(name, Boolean.toString(value), 
            "setBooleanSlotValue");
   }
 
   /**
    * Like setSlotValue(), but results in JavaScript number value. Convenience
    * method which calls setSlotValueScript. 
    * 
    * @see #setSlotValue(String,Object)
    * @see #setSlotValueScript(String,String,String)
    */
   public void setIntSlotValue (String name, Integer value) {
      setSlotValueScript(name, Integer.toString(value), "setIntSlotValue");
   }

   /**
    * Like setSlotValue(), but results in JavaScript string value. Convenience
    * method which calls setSlotValueScript. 
    * 
    * @see #setSlotValue(String,Object)
    * @see #setSlotValueScript(String,String,String)
    */
   public void setStringSlotValue (String name, String value) {
      setSlotValueScript(name, '"'+value+'"', "setIntSlotValue");
   }
   
   /**
    * 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) {
      modified = true;
      // only evaluate expression once (in case of side effects)
      try {
         if ( !evalCondition(
                 "$$value = "+expression+';'+
                 "if ( "+checkExpression(name)+" ) {"+
                    "$this"+'.'+name+" = $$value; $$value = true; "+
                 "} else $$value = false; $$value", where) )
            failCheck(name, expression);
      } finally { bindings.remove("$$value"); }
   }

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

   private String checkExpression (String name) {
      String type = getType().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) {
      modified = true;
      try {
         extra.put("$$this", bindings.get("$this"));
         if ( !((Boolean) eval(
                 "$$value = "+expression+';'+
                 "if ( "+checkExpression(name)+" ) {"+
                    "$$this"+'.'+name+" = $$value; $$value = true; "+
                 "} else $$value = false; $$value", extra, where)) )
            failCheck(name, expression);
      } 
      catch (ScriptException e) { throw new RuntimeException(e); }
      finally { extra.remove("$$value"); extra.remove("$$this"); if (name.equals("when")) getWhen(); }
   }
   
   /**
    * Make named slot undefined.
    * 
    * @see #isDefinedSlot(String)
    */
   public void deleteSlotValue (String name) {
      modified = true;
      eval("delete $this."+name, "deleteSlotValue");
   }
   
   public Boolean isApplicable () {
      // empty string evaluates to null
      return evalCondition(getType().xpath("./precondition"),
            getType()+" precondition");
   }
   
   public Boolean isAchieved () {
      // empty string evaluates to null
      return evalCondition(getType().xpath("./postcondition"),
            getType()+" 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 { 
    	  if (isDefinedSlot("when")) {
	    	  cachedWhen =  ((Double) // for some reason, millis get converted to double first 
	               engine.eval("$this.when.getTime()", bindings, "getWhen")).longValue();
    	  }
    	  return cachedWhen;
      } 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");
      // TODO: hack!  cache when
      cachedWhen = milliseconds;
   }
   
   private long cachedWhen = 0;
   
   /**
    * Test whether this task instance occurred.
    */
   public boolean occurred () {
	   // TODO: hack!  check cached when
	   return cachedWhen != 0;
	   // return getSlotValue("when") != null;
   }
      
   /**
    * 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) {
      setBooleanSlotValue("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) {
      setBooleanSlotValue("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; 
   }
   
   /**
    * Return appropriate and applicable script to ground this task, or null if none.
    */
   public Script getScript () {
      List<Script> scripts = getType().getScripts();
      for (Iterator<Script> i = scripts.iterator(); i.hasNext();) {
         Script 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 ( scripts.isEmpty() ) { 
         // look for default script for model and then overall (must be toplevel)
         List<Node> nodes = engine.xpathAll("./script[@init!=\"true\"]");
         Script global = null;
         for (Node node : nodes) {
            Script script = getType().getScript(node, null);
            if ( script.getTask() == null ) {
               String model = script.getModel();
               // not checking for multiple global scripts
               if ( model == null ) global = script; 
               else if ( model.equals(getType().getNamespace()) )
                  scripts.add(script);
            }
         }
         if ( scripts.isEmpty() && global != null )
            scripts.add(global);
      }
      if ( scripts.isEmpty() ) return null;
      if ( scripts.size() > 1 ) 
         System.out.println("Ignoring multiple scripts for "+this);
      Script script = scripts.get(0);
      return Utils.isFalse(script.isApplicable(this)) ? null : script;
   }
   
   /**
    * 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 ( !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 from this task to given task. Note this
    * side-effects the given task and will overwrite existing slot values.
    * 
    * @param task - task of same type
    * @return true if any slots of given task overwritten (were defined)
    * 
    * @see #matches(Task)
    */
   public boolean copySlotValues (Task task) {
      boolean overwrite = false;
      for (String name : getType().getInputNames())
         overwrite = copySlotValue(task, name) || overwrite;
      for (String name : getType().getOutputNames())
         overwrite = copySlotValue(task, name) || overwrite;
      return overwrite;
   }
   
   /**
    * If named slot of this task is defined, copy value to corresponding
    * slot of given task.
    *
    * @param task - task of same type
    * @return true if slot of given task is overwritten (was defined)
    */
   public boolean copySlotValue (Task task, String name) {
      if ( isDefinedSlot(name) ) {
         boolean overwrite = task.isDefinedSlot(name);
         task.setSlotValueScript(name, "$this."+name, "copySlotValue", bindings);
         return overwrite;
      } else return false;
   }
   
   @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 :
         (getProperty("@indefinite")+' '
               +Utils.decapitalize(getType().getSlotType(name)));
   }
   
   public String getProperty (String key) { return getType().getProperty(key); }
}