/*
 * Copyright (C) 2011. All rights reserved.
 */
package ro.isdc.wro.extensions.script;

import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.mozilla.javascript.EcmaError;
import org.mozilla.javascript.JavaScriptException;
import org.mozilla.javascript.RhinoException;
import org.mozilla.javascript.ScriptableObject;


/**
 * Direct conversion between native Rhino objects and JSON.
 * <p>
 * This class can be used directly in Rhino.
 * <p>
 * This class was inspired from {@link http
 * ://mongodb-rhino.googlecode.com/svn/trunk/modules/com.mongodb.rhino/src/com/mongodb/rhino/JSON.java}
 * 
 * @author Alex Objelean
 * @since 1.3.6
 */
public class RhinoUtils {
  private static final Pattern[] ESCAPE_PATTERNS = new Pattern[] {
    Pattern.compile("\\\\"), Pattern.compile("\\n"), Pattern.compile("\\r"), Pattern.compile("\\t"),
    Pattern.compile("\\f"), Pattern.compile("\\\"")
  };
  
  private static final String[] ESCAPE_REPLACEMENTS = new String[] {
    Matcher.quoteReplacement("\\\\"), Matcher.quoteReplacement("\\n"), Matcher.quoteReplacement("\\r"),
    Matcher.quoteReplacement("\\t"), Matcher.quoteReplacement("\\f"), Matcher.quoteReplacement("\\\"")
  };

  /**
   * Prevent instantiation.
   */
  private RhinoUtils() {
  }
  
  /**
   * Creates a more detailed message based on {@link RhinoException} thrown by rhino execution. The message will contain
   * a detailed description of the problem by inspecting the JSON value provided by exception.
   * 
   * @param e
   *          {@link RhinoException} thrown by rhino execution.
   * @return detailed string message.
   */
  public static String createExceptionMessage(final RhinoException e) {
    StringBuffer message = new StringBuffer("Could not execute the script because: \n");
    if (e instanceof JavaScriptException) {
      message.append(toJson(((JavaScriptException) e).getValue()));
    } else if (e instanceof EcmaError) {
      final EcmaError ecmaError = (EcmaError) e;
      message.append(String.format("Error message: %s at line: %s. \nSource: %s", ecmaError.getErrorMessage(),
          ecmaError.lineNumber(), ecmaError.lineSource()));
    } else {
      message.append(e.getMessage());
    }
    return message.toString();
  }
  
  /**
   * Recursively convert from native Rhino to JSON.
   * <p>
   * Recognizes JavaScript objects, arrays and primitives.
   * <p>
   * Special support for JavaScript dates: converts to {"$date": timestamp} in JSON.
   * <p>
   * Special support for MongoDB ObjectId: converts to {"$oid": "objectid"} in JSON.
   * <p>
   * Also recognizes JVM types: java.util.Map, java.util.Collection, java.util.Date.
   * 
   * @param object
   *          A Rhino native object
   * @return The JSON string
   * @see RhinoUtils#convertSpecial(Object)
   */
  public static String toJson(final Object object) {
    return toJson(object, true);
  }
  
  /**
   * Recursively convert from native Rhino to JSON.
   * <p>
   * Recognizes JavaScript objects, arrays and primitives.
   * <p>
   * Special support for JavaScript dates: converts to {"$date": timestamp} in JSON.
   * <p>
   * Special support for MongoDB ObjectId: converts to {"$oid": "objectid"} in JSON.
   * <p>
   * Also recognizes JVM types: java.util.Map, java.util.Collection, java.util.Date.
   * 
   * @param object
   *          A Rhino native object
   * @param indent
   *          Whether to indent the JSON for human readability
   * @return The JSON string
   */
  public static String toJson(final Object object, final boolean indent) {
    final StringBuilder s = new StringBuilder();
    encode(s, object, indent, indent ? 0 : -1);
    return s.toString();
  }
  
  private static void encode(final StringBuilder s, final Object object, final boolean indent, final int depth) {
    if (indent)
      indent(s, depth);
    
    if (object == null) {
      s.append("null");
    } else if (object instanceof Number) {
      s.append(object);
    } else if (object instanceof Boolean) {
      s.append(object);
    } else if (object instanceof Date) {
      final HashMap<String, Long> map = new HashMap<String, Long>();
      map.put("$date", ((Date) object).getTime());
      encode(s, map, depth);
    } else if (object instanceof Collection) {
      encode(s, (Collection<?>) object, depth);
    } else if (object instanceof Map) {
      encode(s, (Map<?, ?>) object, depth);
      // } else if (object instanceof NativeArray) {
      // final NativeArray nativeArray = (NativeArray) object;
      // encode(s, nativeArray, depth);
    } else if (object instanceof ScriptableObject) {
      final ScriptableObject scriptable = (ScriptableObject) object;
      if (scriptable.getClassName().equals("Date")) {
        // (The NativeDate class is private in Rhino, but we can access
        // it like a regular object.)
        
        final Object time = ScriptableObject.callMethod(scriptable, "getTime", null);
        if (time instanceof Number) {
          encode(s, new Date(((Number) time).longValue()), false, depth);
          return;
        }
      }
      
      encode(s, scriptable, depth);
    } else {
      s.append('\"');
      s.append(escape(object.toString()));
      s.append('\"');
    }
  }
  
  private static void encode(final StringBuilder s, final Collection<?> collection, final int depth) {
    s.append('[');
    
    final Iterator<?> i = collection.iterator();
    if (i.hasNext()) {
      if (depth > -1) {
        s.append('\n');
      }
      
      while (true) {
        final Object value = i.next();
        
        encode(s, value, true, depth > -1 ? depth + 1 : -1);
        
        if (i.hasNext()) {
          s.append(',');
          if (depth > -1) {
            s.append('\n');
          }
        } else
          break;
      }
      
      if (depth > -1) {
        s.append('\n');
      }
    }
    
    if (depth > -1) {
      indent(s, depth);
    }
    s.append(']');
  }
  
  private static void encode(final StringBuilder s, final Map<?, ?> map, final int depth) {
    s.append('{');
    
    final Iterator<?> i = map.entrySet().iterator();
    if (i.hasNext()) {
      if (depth > -1) {
        s.append('\n');
      }
      
      while (true) {
        final Map.Entry<?, ?> entry = (Map.Entry<?, ?>) i.next();
        final String key = entry.getKey().toString();
        final Object value = entry.getValue();
        
        if (depth > -1) {
          indent(s, depth + 1);
        }
        
        s.append('\"');
        s.append(escape(key));
        s.append("\":");
        
        if (depth > -1) {
          s.append(' ');
        }
        
        encode(s, value, false, depth > -1 ? depth + 1 : -1);
        
        if (i.hasNext()) {
          s.append(',');
          if (depth > -1) {
            s.append('\n');
          }
        } else
          break;
      }
      
      if (depth > -1) {
        s.append('\n');
      }
    }
    
    if (depth > -1) {
      indent(s, depth);
    }
    s.append('}');
  }
  
  private static void encode(final StringBuilder s, final ScriptableObject object, final int depth) {
    s.append('{');
    
    final Object[] ids = object.getAllIds();
    if (ids.length > 0) {
      if (depth > -1) {
        s.append('\n');
      }
      
      final int length = ids.length;
      for (int i = 0; i < length; i++) {
        final String key = ids[i].toString();
        final Object value = ScriptableObject.getProperty(object, key);
        
        if (depth > -1) {
          indent(s, depth + 1);
        }
        
        s.append('\"');
        s.append(escape(key));
        s.append("\":");
        
        if (depth > -1) {
          s.append(' ');
        }
        
        encode(s, value, false, depth > -1 ? depth + 1 : -1);
        
        if (i < length - 1) {
          s.append(',');
          if (depth > -1) {
            s.append('\n');
          }
        }
      }
      
      if (depth > -1) {
        s.append('\n');
      }
    }
    
    if (depth > -1) {
      indent(s, depth);
    }
    s.append('}');
  }
  
  private static void indent(final StringBuilder s, final int depth) {
    for (int i = depth - 1; i >= 0; i--) {
      s.append("  ");
    }
  }
  
  private static String escape(final String string) {
    String result = null;
    for (int i = 0, length = ESCAPE_PATTERNS.length; i < length; i++) {
      result = ESCAPE_PATTERNS[i].matcher(string).replaceAll(ESCAPE_REPLACEMENTS[i]);
    }
    return result;
  }
}