package org.commcare.android.storage.framework;

import androidx.annotation.Nullable;

import org.commcare.models.framework.Persisting;
import org.commcare.modern.models.MetaField;
import org.javarosa.core.services.storage.IMetaData;
import org.javarosa.core.services.storage.Persistable;
import org.javarosa.core.util.externalizable.DeserializationException;
import org.javarosa.core.util.externalizable.ExtUtil;
import org.javarosa.core.util.externalizable.PrototypeFactory;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.Hashtable;
import java.util.List;

/**
 * Serialization logic for class fields with @Persisting annotations
 *
 * @author ctsims
 */
@SuppressWarnings("SynchronizationOnLocalVariableOrMethodParameter")
public class Persisted implements Persistable, IMetaData {

    protected int recordId = -1;
    private static final Hashtable<Class, ArrayList<Field>> fieldOrderings = new Hashtable<>();
    private static final Comparator<Field> orderedComparator = (f1, f2) -> {
        int i1 = f1.getAnnotation(Persisting.class).value();
        int i2 = f2.getAnnotation(Persisting.class).value();
        return (i1 < i2 ? -1 : (i1 == i2 ? 0 : 1));
    };

    @Override
    public void readExternal(DataInputStream in, PrototypeFactory pf)
            throws IOException, DeserializationException {
        recordId = ExtUtil.readInt(in);
        String currentField = null;
        try {
            for (Field f : getPersistedFieldsInOrder(getClass())) {
                currentField = f.getName();
                readVal(f, this, in);
            }
        } catch (IllegalAccessException iae) {
            String message = currentField == null ? "" : (" for field " + currentField);
            throw new DeserializationException(message, iae);
        }
    }

    private static void readVal(Field f, Object o, DataInputStream in)
            throws DeserializationException, IOException, IllegalAccessException {
        synchronized (f) {
            // 'f' is a cached field: sync access across threads
            Persisting p = f.getAnnotation(Persisting.class);
            Class type = f.getType();
            try {
                f.setAccessible(true);

                if (type.equals(String.class)) {
                    String read = ExtUtil.readString(in);
                    f.set(o, p.nullable() ? ExtUtil.nullIfEmpty(read) : read);
                    return;
                } else if (type.equals(Integer.TYPE)) {
                    //Primitive Integers
                    f.setInt(o, ExtUtil.readInt(in));
                    return;
                } else if (type.equals(Long.TYPE)) {
                  f.setLong(o, ExtUtil.readLong(in));
                  return;
                } else if (type.equals(Date.class)) {
                    f.set(o, ExtUtil.readDate(in));
                    return;
                } else if (type.isArray()) {
                    //We only support byte arrays for now
                    if (type.getComponentType().equals(Byte.TYPE)) {
                        f.set(o, ExtUtil.readBytes(in));
                        return;
                    }
                } else if (type.equals(Boolean.TYPE)) {
                    f.setBoolean(o, ExtUtil.readBool(in));
                    return;
                }
            } finally {
                f.setAccessible(false);
            }

            //By Default
            throw new DeserializationException("Couldn't read persisted type " + f.getType().toString());
        }
    }

    @Override
    public void writeExternal(DataOutputStream out) throws IOException {
        ExtUtil.writeNumeric(out, recordId);
        try {
            for (Field f : getPersistedFieldsInOrder(getClass())) {
                writeVal(f, this, out);
            }
        } catch (IllegalAccessException iae) {
            throw new RuntimeException(iae);
        }
    }

    private static void writeVal(Field f, Object o, DataOutputStream out)
            throws IOException, IllegalAccessException {
        synchronized (f) {
            // 'f' is a cached field: sync access across threads
            try {
                Persisting p = f.getAnnotation(Persisting.class);
                Class type = f.getType();
                f.setAccessible(true);

                if (type.equals(String.class)) {
                    String s = (String)f.get(o);
                    ExtUtil.writeString(out, p.nullable() ? ExtUtil.emptyIfNull(s) : s);
                    return;
                } else if (type.equals(Integer.TYPE)) {
                    ExtUtil.writeNumeric(out, f.getInt(o));
                    return;
                } else if (type.equals(Long.TYPE)) {
                    ExtUtil.writeNumeric(out, f.getLong(o));
                    return;
                } else if (type.equals(Date.class)) {
                    ExtUtil.writeDate(out, (Date)f.get(o));
                    return;
                } else if (type.isArray()) {
                    //We only support byte arrays for now
                    if (type.getComponentType().equals(Byte.TYPE)) {
                        ExtUtil.writeBytes(out, (byte[])f.get(o));
                        return;
                    }
                } else if (type.equals(Boolean.TYPE)) {
                    ExtUtil.writeBool(out, f.getBoolean(o));
                    return;
                }
            } finally {
                f.setAccessible(false);
            }

            //By Default
            throw new RuntimeException("Couldn't write persisted type " + f.getType().toString());
        }
    }

    private static ArrayList<Field> getPersistedFieldsInOrder(Class persistedClass) {
        ArrayList<Field> orderings;
        synchronized (fieldOrderings) {
            // Since fields are cached, we must sync changes in their
            // accessibility across threads.
            orderings = fieldOrderings.get(persistedClass);
            if (orderings == null) {
                orderings = new ArrayList<>();
                fieldOrderings.put(persistedClass, orderings);
            }
        }
        synchronized (orderings) {
            if (orderings.size() == 0) {
                for (Field f : persistedClass.getDeclaredFields()) {
                    if (f.isAnnotationPresent(Persisting.class)) {
                        orderings.add(f);
                    }
                }
                Collections.sort(orderings, orderedComparator);
            }
            return orderings;
        }
    }

    @Override
    public String[] getMetaDataFields() {
        ArrayList<String> fields = new ArrayList<>();

        addClassFieldsToMetas(fields, getClass());
        addClassMethodsToMetas(fields, getClass());

        return fields.toArray(new String[fields.size()]);
    }

    private static void addClassFieldsToMetas(List<String> fields, Class persistedClass) {
        for (Field f : persistedClass.getDeclaredFields()) {
            synchronized (f) {
                try {
                    f.setAccessible(true);

                    if (f.isAnnotationPresent(MetaField.class)) {
                        MetaField mf = f.getAnnotation(MetaField.class);
                        fields.add(mf.value());
                    }
                } finally {
                    f.setAccessible(false);
                }
            }
        }
    }

    private static void addClassMethodsToMetas(List<String> fields, Class persistedClass) {
        for (Method m : persistedClass.getDeclaredMethods()) {
            synchronized (m) {
                try {
                    m.setAccessible(true);

                    MetaField mf = m.getAnnotation(MetaField.class);
                    if (mf != null) {
                        fields.add(mf.value());
                    }
                } finally {
                    m.setAccessible(false);
                }
            }
        }
    }

    @Nullable
    @Override
    public Object getMetaData(String fieldName) {
        try {
            for (Field f : this.getClass().getDeclaredFields()) {
                synchronized (f) {
                    try {
                        f.setAccessible(true);

                        if (f.isAnnotationPresent(MetaField.class)) {
                            MetaField mf = f.getAnnotation(MetaField.class);
                            if (mf.value().equals(fieldName)) {
                                return f.get(this);
                            }
                        }
                    } finally {
                        f.setAccessible(false);
                    }
                }
            }

            for (Method m : this.getClass().getDeclaredMethods()) {
                synchronized (m) {
                    try {
                        m.setAccessible(true);

                        MetaField mf = m.getAnnotation(MetaField.class);
                        if (mf != null && mf.value().equals(fieldName)) {
                            return m.invoke(this, (Object[])null);
                        }
                    } finally {
                        m.setAccessible(false);
                    }
                }
            }
        } catch (InvocationTargetException | IllegalArgumentException
                | IllegalAccessException e) {
            throw new RuntimeException(e.getMessage());
        }
        //If we didn't find the field
        throw new IllegalArgumentException("No metadata field " + fieldName + " in the case storage system");
    }

    @Override
    public void setID(int ID) {
        recordId = ID;
    }

    @Override
    public int getID() {
        return recordId;
    }
}