package me.denley.courier;

import android.content.Context;

import com.google.android.gms.wearable.DataItem;
import com.google.android.gms.wearable.DataMap;
import com.google.android.gms.wearable.DataMapItem;
import com.google.android.gms.wearable.PutDataMapRequest;
import com.google.android.gms.wearable.PutDataRequest;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * This class contains various static methods used to serialize and deserialize objects into
 * DataMaps, DataItems, and byte arrays. This in turn, allows objects to be transferred
 * to other devices using the Wearable API.
 */
@SuppressWarnings("unused")
public final class Packager {

    private static final Map<Class, DataPackager> PACKAGERS = new LinkedHashMap<Class, DataPackager>();

    /** For use by generated code. Don't use this. */
    public interface DataPackager<T> {
        public DataMap pack(T target);
        public void pack(T target, DataMap map);
        public T unpack(Context context, DataMap map);
    }

    /**
     * In general, this method will only be used by generated code. However, it may be suitable
     * to use this method in some cases (such as in a WearableListenerService).
     *
     * Packages the given object into a PutDataRequest for the specified path.
     *
     * This method will attempt to convert the object to a DataMap using generated code from
     * the {@link Deliverable} annotation.
     *
     * If that fails (e.g. if the object's class was not annotated with {@link Deliverable}),
     * then the object will be converted using the {@link java.io.Serializable} system.
     *
     * If both of these methods are not possible, a {@link java.lang.ClassCastException} will be thrown.
     *
     * @param path  The Wearable API path that the data will be sent on.
     * @param data  The object to serialize into bytes.
     * @return      A PutDataRequest for the given path that encapsulates the given object.
     */
    @SuppressWarnings("unchecked")
    public static PutDataRequest pack(String path, Object data) {
        try {
            final PutDataMapRequest request = PutDataMapRequest.create(path);
            final DataPackager packager = getDataPackager(data.getClass());
            packager.pack(data, request.getDataMap());
            return request.asPutDataRequest();
        } catch (Exception e) {
            final PutDataRequest request = PutDataRequest.create(path);
            request.setData(packSerializable((Serializable)data));
            return request;
        }
    }

    /**
     * In general, this method will only be used by generated code. However, it may be suitable
     * to use this method in some cases (such as in a WearableListenerService).
     *
     * Packages the given array of objects into an array of DataMaps.
     *
     * This method will attempt to convert each object to a DataMap using generated code from
     * the {@link Deliverable} annotation.
     *
     * If this is not possible, a {@link java.lang.RuntimeException} will be thrown.
     *
     * @param deliverablse  The objectn to serialize into DataMaps.
     * @return An ArrayList of DataMaps representing the the object.
     * @throws java.lang.RuntimeException If the packager for the object's class could not be found
     */
    public static ArrayList<DataMap> pack(ArrayList<?> deliverables) {
        final ArrayList<DataMap> packed = new ArrayList<DataMap>();
        if(deliverables != null) {
            for(Object deliverable:deliverables) {
                packed.add(pack(deliverable));
            }
        }
        return packed;
    }

    /**
     * In general, this method will only be used by generated code. However, it may be suitable
     * to use this method in some cases (such as in a WearableListenerService).
     *
     * Packages the given object into a byte array.
     *
     * This method will attempt to convert the object to a DataMap using generated code from
     * the {@link Deliverable} annotation (and then converted to a byte array from the DataMap).
     *
     * If that fails (e.g. if the object's class was not annotated with {@link Deliverable}),
     * then the object will be converted using the {@link java.io.Serializable} system.
     *
     * If both of these methods are not possible, a {@link java.lang.ClassCastException} will be thrown.
     *
     * @param deliverable  The object to serialize into bytes.
     * @return A byte array representing the serialized form of the object.
     */
    public static byte[] packBytes(Object deliverable) {
        try {
            return pack(deliverable).toByteArray();
        } catch (Exception e) {
            return packSerializable((Serializable) deliverable);
        }
    }

    /**
     * In general, this method will only be used by generated code. However, it may be suitable
     * to use this method in some cases (such as in a WearableListenerService).
     *
     * Packages the given object into a DataMap.
     *
     * This method will attempt to convert the object to a DataMap using generated code from
     * the {@link Deliverable} annotation.
     *
     * If this is not possible, a {@link java.lang.ClassCastException} will be thrown.
     *
     * @param deliverable  The object to serialize into a DataMap.
     * @return A DataMap representing the the object.
     * @throws java.lang.RuntimeException If the packager for the object's class could not be found
     */
    @SuppressWarnings("unchecked")
    public static DataMap pack(Object deliverable) {
        if(deliverable==null) {
            return null;
        }

        final DataPackager packager = getDataPackager(deliverable.getClass());
        if(packager == null) {
            throw new RuntimeException("Unable to find packager for the given object. " +
                    "Please ensure that the object's class is annotated with @Deliverable, " +
                    "and that the annotation processor has run correctly");
        }
        return packager.pack(deliverable);
    }

    /**
     * In general, this method will only be used by generated code. However, it may be suitable
     * to use this method in some cases (such as in a WearableListenerService).
     *
     * Packages the given object into a byte array.
     *
     * This method will attempt to convert the object to a byte array using the {@link java.io.Serializable} system.
     *
     * If this is not possible, an {@link java.lang.IllegalArgumentException} will be thrown.
     *
     * @param object  The object to serialize into bytes.
     * @return A byte array representing the serialized form of the object.
     */
    public static byte[] packSerializable(Serializable object) {
        if(object==null) {
            return new byte[0];
        }

        try {
            final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
            final ObjectOutputStream out = new ObjectOutputStream(bytes);
            out.writeObject(object);

            return bytes.toByteArray();
        }catch (IOException e){
            throw new IllegalArgumentException("Unable to serialize object", e);
        }
    }

    /**
     * In general, this method will only be used by generated code. However, it may be suitable
     * to use this method in some cases (such as in a WearableListenerService).
     *
     * Unpacks the given byte array into an object.
     *
     * This method will use the {@link java.io.Serializable} system to deserialize the data.
     *
     * @param data  The byte array to deserialize into an object.
     * @return An object deserialized from the byte array.
     */
    @SuppressWarnings("unchecked")
    public static <T> T unpackSerializable(byte[] data) {
        if(data==null || data.length==0) {
            return null;
        }

        try {
            final ByteArrayInputStream bytes = new ByteArrayInputStream(data);
            final ObjectInputStream in = new ObjectInputStream(bytes);

            return (T)in.readObject();
        }catch (Exception e){
            throw new IllegalArgumentException("Unable to deserialize object", e);
        }
    }

    /**
     * In general, this method will only be used by generated code. However, it may be suitable
     * to use this method in some cases (such as in a WearableListenerService).
     *
     * Unpacks the given ArrayList of DataMaps into an ArrayList of objects of the given class.
     *
     * This method will attempt to use generated code from the {@link Deliverable}
     * annotation to convert each DataMap to an object of the given class.
     *
     * @param context The Context that may be used to load Assets from the data.
     * @param maps  The DataItems to load the object from.
     * @param targetClass The class of object to unpack.
     * @return An ArrayList of objects of the given class.
     * * @throws java.lang.RuntimeException If the packager for targetClass could not be found
     */
    public static <T> ArrayList<T> unpack(Context context, ArrayList<DataMap> maps, Class<T> targetClass) {
        if(maps==null) {
            return null;
        }

        final DataPackager<T> packager = getDataPackager(targetClass);
        if(packager == null) {
            throw new RuntimeException("Unable to find packager for " + targetClass.toString() +
                    "Please ensure that it is annotated with @Deliverable, " +
                    "and that the annotation processor has run correctly");
        }

        final ArrayList<T> unpacked = new ArrayList<T>();
        for(DataMap map:maps) {
            unpacked.add(packager.unpack(context, map));
        }
        return unpacked;
    }

    /**
     * In general, this method will only be used by generated code. However, it may be suitable
     * to use this method in some cases (such as in a WearableListenerService).
     *
     * Unpacks the given DataItem into an object of the given class.
     *
     * This method will attempt to load a DataMap from the DataItem and then use generated code from
     * the {@link Deliverable} annotation to convert it to an object of the given class.
     *
     * If this is not possible, this method will then attempt to deserialize the byte array contained
     * in the DataItem using the {@link java.io.Serializable} system.
     *
     * @param context The Context that may be used to load Assets from the data.
     * @param data  The DataItem to load the object from.
     * @param targetClass The class of object to unpack.
     * @return An object of the given class.
     */
    public static <T> T unpack(Context context, DataItem data, Class<T> targetClass) {
        try {
            final DataMapItem dataMapItem = DataMapItem.fromDataItem(data);
            return unpack(context, dataMapItem.getDataMap(), targetClass);
        }catch (Exception e) {
            return unpackSerializable(data.getData());
        }
    }

    /**
     * In general, this method will only be used by generated code. However, it may be suitable
     * to use this method in some cases (such as in a WearableListenerService).
     *
     * Unpacks the given byte array into an object of the given class.
     *
     * This method will attempt to convert the byte array into a DataMap and then use generated code from
     * the {@link Deliverable} annotation to convert it to an object of the given class.
     *
     * If this is not possible, this method will then attempt to deserialize the byte array
     * using the {@link java.io.Serializable} system.
     *
     * @param context The Context that may be used to load Assets from the data.
     * @param data  The byte array to load the object from.
     * @param targetClass The class of object to unpack.
     * @return An object of the given class.
     */
    public static <T> T unpack(Context context, byte[] data, Class<T> targetClass) {
        try {
            final DataMap dataMap = DataMap.fromByteArray(data);
            return unpack(context, dataMap, targetClass);
        }catch (Exception e) {
            return unpackSerializable(data);
        }
    }

    /**
     * In general, this method will only be used by generated code. However, it may be suitable
     * to use this method in some cases (such as in a WearableListenerService).
     *
     * Unpacks the given DataMap into an object of the given class.
     *
     * This method will attempt to use generated code from the {@link Deliverable}
     * annotation to convert the DataMap to an object of the given class.
     *
     * @param context The Context that may be used to load Assets from the data.
     * @param map  The DataItem to load the object from.
     * @param targetClass The class of object to unpack.
     * @return An object of the given class.
     * * @throws java.lang.RuntimeException If the packager for targetClass could not be found
     */
    public static <T> T unpack(Context context, DataMap map, Class<T> targetClass) {
        if(map==null) {
            return null;
        }

        final DataPackager<T> packager = getDataPackager(targetClass);
        if(packager == null) {
            throw new RuntimeException("Unable to find packager for " + targetClass.toString() +
                    "Please ensure that it is annotated with @Deliverable, " +
                    "and that the annotation processor has run correctly");
        }
        return packager.unpack(context, map);
    }

    /**
     * In general, this method will only be used by generated code. However, it may be suitable
     * to use this method in some cases (such as in a WearableListenerService).
     *
     * Unpacks the given DataItem into an object of the given class.
     *
     * This method will attempt to load a DataMap from the DataItem and then use generated code from
     * the {@link Deliverable} annotation to convert it to an object of the given class.
     *
     * If this is not possible, this method will then attempt to deserialize the byte array contained
     * in the DataItem using the {@link java.io.Serializable} system.
     *
     * @param data  The DataItem to load the object from.
     * @param targetClass The class of object to unpack.
     * @return An object of the given class.
     * @deprecated Use {@link #unpack(android.content.Context, com.google.android.gms.wearable.DataItem, Class)} instead to allow automatic Asset loading.
     */
    @Deprecated
    public static <T> T unpack(DataItem data, Class<T> targetClass) {
        return unpack(null, data, targetClass);
    }

    /**
     * In general, this method will only be used by generated code. However, it may be suitable
     * to use this method in some cases (such as in a WearableListenerService).
     *
     * Unpacks the given byte array into an object of the given class.
     *
     * This method will attempt to convert the byte array into a DataMap and then use generated code from
     * the {@link Deliverable} annotation to convert it to an object of the given class.
     *
     * If this is not possible, this method will then attempt to deserialize the byte array
     * using the {@link java.io.Serializable} system.
     *
     * @param data  The byte array to load the object from.
     * @param targetClass The class of object to unpack.
     * @return An object of the given class.
     * @deprecated Use {@link #unpack(android.content.Context, byte[], Class)} instead to allow automatic Asset loading.
     */
    @Deprecated
    public static <T> T unpack(byte[] data, Class<T> targetClass) {
        return unpack(null, data, targetClass);
    }

    /**
     * In general, this method will only be used by generated code. However, it may be suitable
     * to use this method in some cases (such as in a WearableListenerService).
     *
     * Unpacks the given DataMap into an object of the given class.
     *
     * This method will attempt to use generated code from the {@link Deliverable}
     * annotation to convert the DataMap to an object of the given class.
     *
     * @param map  The DataItem to load the object from.
     * @param targetClass The class of object to unpack.
     * @return An object of the given class.
     * @deprecated Use {@link #unpack(android.content.Context, com.google.android.gms.wearable.DataMap, Class)} instead to allow automatic Asset loading.
     */
    @Deprecated
    public static <T> T unpack(DataMap map, Class<T> targetClass) {
        return unpack(null, map, targetClass);
    }

    @SuppressWarnings("unchecked")
    private static <T> DataPackager<T> getDataPackager(Class<T> targetClass) {
        DataPackager<T> packager = PACKAGERS.get(targetClass);
        if(packager!=null) {
            return packager;
        }

        try {
            final String packagerClassName = targetClass.getName() + "$$DataMapPackager";
            final Class packagerClass = Class.forName(packagerClassName);
            packager = (DataPackager<T>)packagerClass.newInstance();
            PACKAGERS.put(targetClass, packager);
            return packager;
        }catch (Exception e) {
            return null;
        }
    }


    private Packager(){}

}