package org.mvel2.marshalling;

import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.MathContext;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import org.mvel2.MVEL;
import org.mvel2.integration.impl.MapVariableResolverFactory;
import org.mvel2.tests.BaseMvelTestCase;
import org.mvel2.util.StringAppender;

// import com.thoughtworks.xstream.XStream;

/**
 * Generates templates to marshaller classes.
 * TODO
 * -Currently uses BeanInfo, needs to handle all MVEL getter/setter types
 * -Use objenesis or equivalent to always be able to handle no-arg constructor
 * -handle special immutable classes like BigInteger, BigDecimal(which are already done, but are there others?)
 * -As well as allowing users to register custom templates, maybe also custom built in marshallers (i.e. how map, collection, array currently works)
 * -Support optional generated imports, to reduce verbosity
 * -some issue related to values allowed in a Map
 */
public class MarshallingTest extends BaseMvelTestCase {

  public static enum Type {
    PRIMITIVE, CHAR, STRING, DATE, CALENDAR, BIG_INTEGER, BIG_DECIMAL, ARRAY, MAP, COLLECTION, OBJECT;
  }

  public static class ObjectConverter {
    private Class type;
    private ObjectConverterEntry[] fields;

    public ObjectConverter(Class type,
                           ObjectConverterEntry[] fields) {
      this.type = type;
      this.fields = fields;
    }

    public Class getType() {
      return this.type;
    }

    public ObjectConverterEntry[] getFields() {
      return fields;
    }
  }

  public static class ObjectConverterEntry {
    private String name;
    private Type type;
    private Method method;

    public ObjectConverterEntry(String name,
                                Method method,
                                Type type) {
      this.name = name;
      this.type = type;
      this.method = method;
    }

    public String getName() {
      return name;
    }

    public Type getType() {
      return type;
    }

    public Method getMethod() {
      return this.method;
    }

  }

  public static class MarshallerContext {
    private Marshaller marshaller;
    private StringAppender appender = new StringAppender();

    public MarshallerContext(Marshaller marshaller) {
      this.marshaller = marshaller;
      this.appender = new StringAppender();
    }

    public void marshall(Object object) {
      marshaller.marshall(object,
          this);
    }

    public StringAppender getAppender() {
      return appender;
    }
  }

  public static interface CustomMarshaller {
    public void marshall(Object object,
                         MarshallerContext ctx);
  }

  public static class EpocDateMarshaller
      implements
      CustomMarshaller {

    public void marshall(Object object,
                         MarshallerContext ctx) {
      ctx.getAppender().append("new java.util.Date(" + ((Date) object).getTime() + ")");
    }
  }

  public static class EpocDefaultCalendarMarshaller
      implements
      CustomMarshaller {
    private CustomMarshaller dateMarshaller;

    public EpocDefaultCalendarMarshaller() {
      this(new EpocDateMarshaller());
    }

    public EpocDefaultCalendarMarshaller(CustomMarshaller dateMarshaller) {
      this.dateMarshaller = dateMarshaller;
    }

    public void marshall(Object object,
                         MarshallerContext ctx) {

      ctx.getAppender().append("with ( java.util.Calendar.getInstance() ) { time = ");
      this.dateMarshaller.marshall(((Calendar) object).getTime(),
          ctx);
      ctx.getAppender().append("} ");
    }
  }

  public static class Marshaller {
    private Map<Class, ObjectConverter> converters;
    private CustomMarshaller dateMarshaller;
    private CustomMarshaller calendarMarshaller;

    public Marshaller() {
      this(new HashMap<Type, CustomMarshaller>());
    }

    public Marshaller(Map<Type, CustomMarshaller> custom) {
      this.converters = new HashMap<Class, ObjectConverter>();
      this.dateMarshaller = custom.get(Type.DATE);
      if (this.dateMarshaller == null) {
        this.dateMarshaller = new EpocDateMarshaller();
      }

      this.calendarMarshaller = custom.get(Type.CALENDAR);
      if (this.calendarMarshaller == null) {
        this.calendarMarshaller = new EpocDefaultCalendarMarshaller();
      }
    }

    public void marshall(Object object,
                         MarshallerContext ctx) {
      marshall(object,
          getType(object.getClass()),
          ctx);
    }

    public void marshall(Object object,
                         Type type,
                         MarshallerContext ctx) {
      if (object == null) {
        ctx.getAppender().append("null");
        return;
      }

      if (type != Type.OBJECT) {
        marshallValue(object,
            type,
            ctx);
      }
      else {
        Class cls = object.getClass();
        ObjectConverter converter = this.converters.get(cls);
        if (converter == null) {
          converter = generateConverter(cls);
          this.converters.put(cls,
              converter);
        }

        try {
          int i = 0;
          ctx.getAppender().append("new " + cls.getName() + "().{ ");
          for (ObjectConverterEntry entry : converter.getFields()) {
            if (i++ != 0) {
              ctx.getAppender().append(", ");
            }
            ctx.getAppender().append(entry.getName());
            ctx.getAppender().append(" = ");

            marshallValue(entry.getMethod().invoke(object,
                null),
                entry.getType(),
                ctx);
          }
        }
        catch (Exception e) {
          throw new IllegalStateException("Unable to marshall object " + object,
              e);
        }
        ctx.getAppender().append(" }");
      }
    }

    private void marshallValue(Object object,
                               Type type,
                               MarshallerContext ctx) {
      if (object == null) {
        ctx.getAppender().append("null");
        return;
      }

      switch (type) {
        case PRIMITIVE: {
          ctx.getAppender().append(object);
          break;
        }
        case CHAR: {
          ctx.getAppender().append("'");
          ctx.getAppender().append(object);
          ctx.getAppender().append("'");
          break;
        }
        case STRING: {
          ctx.getAppender().append("'");
          ctx.getAppender().append(object);
          ctx.getAppender().append("'");
          break;
        }
        case DATE: {
          dateMarshaller.marshall(object,
              ctx);
          break;
        }
        case CALENDAR: {
          calendarMarshaller.marshall(object,
              ctx);
          break;
        }
        case BIG_INTEGER: {
          ctx.getAppender().append(object);
          break;
        }
        case BIG_DECIMAL: {
          ctx.getAppender().append(object);
          break;
        }
        case ARRAY: {
          marshallArray(object,
              ctx);
          break;
        }
        case MAP: {
          marshallMap((Map) object,
              ctx);
          break;
        }
        case COLLECTION: {
          marshallCollection((Collection) object,
              ctx);
          break;
        }
        case OBJECT: {
          marshall(object,
              type,
              ctx);
          break;
        }
      }
    }

    private ObjectConverter generateConverter(Class cls) {
      BeanInfo beanInfo = null;

      try {
        beanInfo = Introspector.getBeanInfo(cls);
      }
      catch (IntrospectionException e) {
        throw new RuntimeException(e);
      }

      PropertyDescriptor[] props = beanInfo.getPropertyDescriptors();
      List<ObjectConverterEntry> list = new ArrayList<ObjectConverterEntry>();

      for (int i = 0, length = props.length; i < length; i++) {
        PropertyDescriptor prop = props[i];
        if ("class".equals(prop.getName())) {
          continue;
        }

        list.add(new ObjectConverterEntry(prop.getName(),
            prop.getReadMethod(),
            getType(prop.getPropertyType())));
      }

      return new ObjectConverter(cls,
          list.toArray(new ObjectConverterEntry[list.size()]));
    }

    private Type getType(Class cls) {
      Type type = null;
      if (cls.isPrimitive() || Number.class.isAssignableFrom(cls)) {
        type = Type.PRIMITIVE;
      }
      else if (Character.class.isAssignableFrom(cls)) {
        type = Type.CHAR;
      }
      else if (String.class.isAssignableFrom(cls)) {
        type = Type.STRING;
      }
      else if (Date.class.isAssignableFrom(cls)) {
        type = Type.DATE;
      }
      else if (Calendar.class.isAssignableFrom(cls)) {
        type = Type.CALENDAR;
      }
      else if (BigInteger.class.isAssignableFrom(cls)) {
        type = Type.BIG_INTEGER;
      }
      else if (BigDecimal.class.isAssignableFrom(cls)) {
        type = Type.BIG_DECIMAL;
      }
      else if (cls.isArray()) {
        type = Type.ARRAY;
      }
      else if (Map.class.isAssignableFrom(cls)) {
        type = Type.MAP;
      }
      else if (Collection.class.isAssignableFrom(cls)) {
        type = Type.COLLECTION;
      }
      else {
        type = Type.OBJECT;
      }
      return type;
    }

    private void marshallMap(Map map,
                             MarshallerContext ctx) {
      ctx.getAppender().append(" [ ");
      int i = 0;
      for (Iterator<Entry> it = map.entrySet().iterator(); it.hasNext(); i++) {
        if (i != 0) {
          ctx.getAppender().append(", ");
        }
        Entry entry = it.next();
        marshall(entry.getKey(),
            ctx);
        ctx.getAppender().append(':');
        marshall(entry.getValue(),
            ctx);

      }
      ctx.getAppender().append(" ] ");
    }

    private void marshallCollection(Collection collection,
                                    MarshallerContext ctx) {
      ctx.getAppender().append(" [ ");
      int i = 0;
      for (Iterator it = collection.iterator(); it.hasNext(); i++) {
        if (i != 0) {
          ctx.getAppender().append(", ");
        }
        marshall(it.next(),
            ctx);
      }
      ctx.getAppender().append(" ] ");
    }

    private void marshallArray(Object array,
                               MarshallerContext ctx) {
      ctx.getAppender().append(" { ");

      for (int i = 0, length = Array.getLength(array); i < length; i++) {
        if (i != 0) {
          ctx.getAppender().append(", ");
        }
        marshall(Array.get(array,
            i),
            ctx);
      }
      ctx.getAppender().append(" } ");
    }

    public String marshallToString(Object object) {
      MarshallerContext ctx = new MarshallerContext(this);
      marshall(object,
          ctx);
      return ctx.getAppender().toString();
    }

  }

  private Object getData() {
    Pet pet = new Pet();
    pet.setName("rover");
    pet.setAge(7);
    List list = new ArrayList();
    list.add("a");
    list.add(12);
    list.add(new SomeNumers(10.02f,
        22.02,
        5,
        100l,
        new BigDecimal(23.0234d,
            MathContext.DECIMAL128),
        new BigInteger("1001")));
    list.add(new Date());
    //list.add( 'b' ); // generates ok but breaks round trip equals
    list.add(new Cheese("cheddar",
        6));

    pet.setList(list);
    pet.setArray(new int[]{1, 2, 3});

    Map map = new HashMap();
    //map.put( new Date(), new Cheese( "stilton", 11) ); // TODO why doesn't this work
    map.put("key1",
        13);
    map.put("key3",
        "value3");
    map.put("key2",
        15);
    map.put("key4",
        new Cheese("stilton",
            11));
    Calendar cal = Calendar.getInstance();
//        cal.setTime( new Date() );
//        map.put( "key5",
//                 cal ); // TODO why doesn't this work.
    //map.put( "key4", new String[] { "a", "b" } ); // TODO why doesn't this work

    Person person = new Person();
    person.setName("mark");
    person.setAge(33);
    person.setPet(pet);
    person.setSomeDate(new Date());
    person.setMap(map);
    cal = Calendar.getInstance();
    cal.setTime(new Date());
    person.setCal(cal);

    return person;
  }

  private static final int COUNT = 0;

//    public void testXStream() {
//        XStream xstream = new XStream();
//
//        // run once to allow for caching
//        Object data1 = getData();
//        String str = xstream.toXML( data1 );
//        System.out.println( str );
//        Object data2 = xstream.fromXML( str );
//        assertNotSame( data1,
//                       data2 );
//        assertEquals( data1,
//                      data2 );
//
//        long start = System.currentTimeMillis();
//        for ( int i = 0; i < COUNT; i++ ) {
//            data1 = getData();
//            str = xstream.toXML( data1 );
//            data2 = xstream.fromXML( str );
//            assertNotSame( data1,
//                           data2 );
//            assertEquals( data1,
//                          data2 );
//        }
//        long end = System.currentTimeMillis();
//
//        System.out.println( "xstream : " + (end - start) );
//    }

  public void testMVEL() throws Exception {
    Marshaller marshaller = new Marshaller();

    // run once to generate templates
    Object data1 = getData();
    String str = marshaller.marshallToString(data1);
    System.out.println(str);
    Object data2 = MVEL.eval(str);
    assertNotSame(data1,
        data2);
    assertEquals(data1,
        data2);

    long start = System.currentTimeMillis();
    for (int i = 0; i < COUNT; i++) {
      data1 = getData();
      str = marshaller.marshallToString(data1);
      data2 = MVEL.eval(str);
      assertNotSame(data1,
          data2);
      assertEquals(data1,
          data2);
    }
    long end = System.currentTimeMillis();

    System.out.println("mvel : " + (end - start));
  }

  public static class SomeNumers {
    private float aFloat;
    private double aDouble;
    private int aInt;
    private long aLong;
    private BigDecimal aBigDecimal;
    private BigInteger aBigInteger;

    public SomeNumers() {

    }

    public SomeNumers(float float1,
                      double double1,
                      int int1,
                      long long1,
                      BigDecimal bigDecimal,
                      BigInteger bigInteger) {
      super();
      aFloat = float1;
      aDouble = double1;
      aInt = int1;
      aLong = long1;
      aBigDecimal = bigDecimal;
      aBigInteger = bigInteger;
    }

    public float getAFloat() {
      return aFloat;
    }

    public void setAFloat(float float1) {
      aFloat = float1;
    }

    public double getADouble() {
      return aDouble;
    }

    public void setADouble(double double1) {
      aDouble = double1;
    }

    public int getAInt() {
      return aInt;
    }

    public void setAInt(int int1) {
      aInt = int1;
    }

    public long getALong() {
      return aLong;
    }

    public void setALong(long long1) {
      aLong = long1;
    }

    public BigDecimal getABigDecimal() {
      return aBigDecimal;
    }

    public void setABigDecimal(BigDecimal bigDecimal) {
      aBigDecimal = bigDecimal;
    }

    public BigInteger getABigInteger() {
      return aBigInteger;
    }

    public void setABigInteger(BigInteger bigInteger) {
      aBigInteger = bigInteger;
    }

    @Override
    public int hashCode() {
      final int prime = 31;
      int result = 1;
      result = prime * result + ((aBigDecimal == null) ? 0 : aBigDecimal.hashCode());
      result = prime * result + ((aBigInteger == null) ? 0 : aBigInteger.hashCode());
      long temp;
      temp = Double.doubleToLongBits(aDouble);
      result = prime * result + (int) (temp ^ (temp >>> 32));
      result = prime * result + Float.floatToIntBits(aFloat);
      result = prime * result + aInt;
      result = prime * result + (int) (aLong ^ (aLong >>> 32));
      return result;
    }

    @Override
    public boolean equals(Object obj) {
      if (this == obj) return true;
      if (obj == null) return false;
      if (getClass() != obj.getClass()) return false;
      SomeNumers other = (SomeNumers) obj;
      if (aBigDecimal == null) {
        if (other.aBigDecimal != null) return false;
      }
      else if (!aBigDecimal.equals(other.aBigDecimal)) return false;
      if (aBigInteger == null) {
        if (other.aBigInteger != null) return false;
      }
      else if (!aBigInteger.equals(other.aBigInteger)) return false;
      if (Double.doubleToLongBits(aDouble) != Double.doubleToLongBits(other.aDouble)) return false;
      if (Float.floatToIntBits(aFloat) != Float.floatToIntBits(other.aFloat)) return false;
      if (aInt != other.aInt) return false;
      if (aLong != other.aLong) return false;
      return true;
    }

  }

  public static class Person {
    private String name;
    private int age;
    private Date someDate;

    private Pet pet;

    private Object nullTest;

    private Map map;

    private Calendar cal;

    public String getName() {
      return name;
    }

    public void setName(String name) {
      this.name = name;
    }

    public int getAge() {
      return age;
    }

    public void setAge(int age) {
      this.age = age;
    }

    public Pet getPet() {
      return this.pet;
    }

    public void setPet(Pet pet) {
      this.pet = pet;
    }

    public Date getSomeDate() {
      return someDate;
    }

    public void setSomeDate(Date someDate) {
      this.someDate = someDate;
    }

    public Object getNullTest() {
      return nullTest;
    }

    public void setNullTest(Object nullTest) {
      this.nullTest = nullTest;
    }

    public Map getMap() {
      return map;
    }

    public void setMap(Map map) {
      this.map = map;
    }


    public Calendar getCal() {
      return cal;
    }

    public void setCal(Calendar cal) {
      this.cal = cal;
    }

    @Override
    public int hashCode() {
      final int prime = 31;
      int result = 1;
      result = prime * result + age;
      result = prime * result + ((cal == null) ? 0 : cal.hashCode());
      result = prime * result + ((map == null) ? 0 : map.hashCode());
      result = prime * result + ((name == null) ? 0 : name.hashCode());
      result = prime * result + ((nullTest == null) ? 0 : nullTest.hashCode());
      result = prime * result + ((pet == null) ? 0 : pet.hashCode());
      result = prime * result + ((someDate == null) ? 0 : someDate.hashCode());
      return result;
    }

    @Override
    public boolean equals(Object obj) {
      if (this == obj) return true;
      if (obj == null) return false;
      if (getClass() != obj.getClass()) return false;
      Person other = (Person) obj;
      if (age != other.age) return false;
      if (cal == null) {
        if (other.cal != null) return false;
      }
      else if (!cal.equals(other.cal)) return false;
      if (map == null) {
        if (other.map != null) return false;
      }
      else if (!map.equals(other.map)) return false;
      if (name == null) {
        if (other.name != null) return false;
      }
      else if (!name.equals(other.name)) return false;
      if (nullTest == null) {
        if (other.nullTest != null) return false;
      }
      else if (!nullTest.equals(other.nullTest)) return false;
      if (pet == null) {
        if (other.pet != null) return false;
      }
      else if (!pet.equals(other.pet)) return false;
      if (someDate == null) {
        if (other.someDate != null) return false;
      }
      else if (!someDate.equals(other.someDate)) return false;
      return true;
    }

  }

  public static class Pet {
    private String name;
    private Integer age;

    private List list;
    private int[] array;

    public String getName() {
      return name;
    }

    public void setName(String name) {
      this.name = name;
    }

    public Integer getAge() {
      return age;
    }

    public void setAge(Integer agr) {
      this.age = agr;
    }

    public List getList() {
      return list;
    }

    public void setList(List list) {
      this.list = list;
    }

    public int[] getArray() {
      return array;
    }

    public void setArray(int[] array) {
      this.array = array;
    }

    @Override
    public int hashCode() {
      final int prime = 31;
      int result = 1;
      result = prime * result + ((age == null) ? 0 : age.hashCode());
      result = prime * result + Arrays.hashCode(array);
      result = prime * result + ((list == null) ? 0 : list.hashCode());
      result = prime * result + ((name == null) ? 0 : name.hashCode());
      return result;
    }

    @Override
    public boolean equals(Object obj) {
      if (this == obj) return true;
      if (obj == null) return false;
      if (getClass() != obj.getClass()) return false;
      Pet other = (Pet) obj;
      if (age == null) {
        if (other.age != null) return false;
      }
      else if (!age.equals(other.age)) return false;
      if (!Arrays.equals(array,
          other.array)) return false;
      if (list == null) {
        if (other.list != null) return false;
      }
      else if (!list.equals(other.list)) return false;
      if (name == null) {
        if (other.name != null) return false;
      }
      else if (!name.equals(other.name)) return false;
      return true;
    }

  }

  public static class Cheese {
    private String type;
    private int age;
    private boolean edible;

    public Cheese() {

    }

    public Cheese(String type,
                  int age) {
      this.type = type;
      this.age = age;
    }

    public String getType() {
      return type;
    }

    public void setType(String type) {
      this.type = type;
    }

    public int getAge() {
      return age;
    }

    public void setAge(int age) {
      this.age = age;
    }

    public boolean isEdible() {
      return edible;
    }

    public void setEdible(boolean edible) {
      this.edible = edible;
    }

    @Override
    public int hashCode() {
      final int prime = 31;
      int result = 1;
      result = prime * result + age;
      result = prime * result + (edible ? 1231 : 1237);
      result = prime * result + ((type == null) ? 0 : type.hashCode());
      return result;
    }

    @Override
    public boolean equals(Object obj) {
      if (this == obj) return true;
      if (obj == null) return false;
      if (getClass() != obj.getClass()) return false;
      Cheese other = (Cheese) obj;
      if (age != other.age) return false;
      if (edible != other.edible) return false;
      if (type == null) {
        if (other.type != null) return false;
      }
      else if (!type.equals(other.type)) return false;
      return true;
    }

  }
}
