package tc.oc.api.document;

import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Field;
import java.lang.reflect.Member;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.annotation.Nullable;
import javax.inject.Inject;
import javax.inject.Singleton;

import com.google.api.client.repackaged.com.google.common.base.Joiner;
import com.google.common.base.Function;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.gson.annotations.SerializedName;
import com.google.inject.Injector;
import tc.oc.api.docs.BasicDeletableModel;
import tc.oc.api.docs.BasicModel;
import tc.oc.api.docs.PlayerId;
import tc.oc.api.docs.SimplePlayerId;
import tc.oc.api.docs.SimpleUserId;
import tc.oc.api.docs.UserId;
import tc.oc.api.docs.virtual.BasicDocument;
import tc.oc.api.docs.virtual.DeletableModel;
import tc.oc.api.docs.virtual.Document;
import tc.oc.api.docs.virtual.Model;
import tc.oc.api.exceptions.SerializationException;
import tc.oc.commons.core.logging.Loggers;
import tc.oc.commons.core.reflect.Types;
import tc.oc.commons.core.util.C3;
import tc.oc.commons.core.util.MapUtils;

/**
 * Cache of {@link DocumentMeta} records, populated on-demand when a {@link Document}
 * is serialized or deserialized.
 */
@Singleton
public class DocumentRegistry {

    protected final Logger logger;
    protected final DocumentGenerator generator;
    protected final Injector injector;

    private final LoadingCache<Class<? extends Document>, DocumentMeta> cache = CacheBuilder.newBuilder().build(
        new CacheLoader<Class<? extends Document>, DocumentMeta>() {
            @Override
            public DocumentMeta load(Class<? extends Document> type) throws Exception {
                return register(type);
            }
        }
    );

    @Inject DocumentRegistry(Loggers loggers, DocumentGenerator generator, Injector injector) {
        this.generator = generator;
        this.injector = injector;
        this.logger = loggers.get(getClass());
    }

    /**
     * Is the given document type directly instantiable? This is true only
     * if the type is a non-abstract class with a default constructor.
     */
    public boolean isInstantiable(Class<? extends Document> type) {
        if(type.isInterface() || Modifier.isAbstract(type.getModifiers())) return false;
        try {
            type.getDeclaredConstructor();
            return true;
        } catch(NoSuchMethodException e) {
            return false;
        }
    }

    public <T extends Document> T instantiate(Class<T> type, Map<String, Object> data) {
        return instantiate(getMeta(type), data);
    }

    public <T extends Document> T instantiate(DocumentMeta<T> meta, Map<String, Object> data) {
        if(meta.type().isInterface()) {
            // To create an interface document, choose the best base type,
            // instantiate that, and then wrap it in a proxy that implements
            // the rest of the properties.
            return generator.instantiate(meta, instantiate(getMeta(meta.baseType()), data), data);

        } else if(isInstantiable(meta.type())) {
            // If document type is directly instantiable, get an instance
            // from the injector and use setters to initialize it.
            final T doc = injector.getInstance(meta.type());
            for(Map.Entry<String, Setter> entry : meta.setters().entrySet()) {
                if(data.containsKey(entry.getKey())) {
                    entry.getValue().setUnchecked(doc, data.get(entry.getKey()));
                }
            }
            return doc;

        } else {
            throw new SerializationException("Document type " + meta.type().getName() + " is not instantiable");
        }
    }

    public <T extends Document> T copy(T original) {
        final DocumentMeta<T> meta = getMeta((Class<T>) original.getClass());
        return instantiate(meta, ImmutableMap.copyOf(Maps.transformValues(meta.getters(), getter -> getter.get(original))));
    }

    /**
     * Get (or create) the metadata for the given {@link Document} type.
     */
    public <T extends Document> DocumentMeta<T> getMeta(Class<T> type) {
        return cache.getUnchecked(type);
    }

    private <T extends Document> DocumentMeta<T> register(final Class<T> type) {
        logger.fine("Registering serializable type " + type);

        // Find property accessors declared directly on the given document
        final Map<String, Getter> getters = new HashMap<>();
        final Map<String, Setter> setters = new HashMap<>();
        for(Method method : DocumentMeta.serializedMethods(type)) {
            registerMethod(getters, setters, method);
        }
        for(Field field : DocumentMeta.serializedFields(type)) {
            registerField(getters, setters, field);
        }

        // Find the immediate supertypes of the document
        final List<DocumentMeta<? super T>> parents = new ArrayList<>();
        for(Class<? super T> parent : Types.parents(type)) {
            if(Document.class.isAssignableFrom(parent)) {
                parents.add((DocumentMeta<? super T>) getMeta(parent.asSubclass(Document.class)));
            }
        }

        // Merge all ancestors into a single list
        final List<DocumentMeta<? super T>> ancestors = ImmutableList.copyOf(
            C3.merge(
                Lists.transform(
                    parents,
                    (Function<DocumentMeta<? super T>, Collection<? extends DocumentMeta<? super T>>>) DocumentMeta::ancestors
                )
            )
        );

        if(logger.isLoggable(Level.FINE)) {
            logger.fine("Linearized ancestors for " + type + ": " + Joiner.on(", ").join(ancestors));
        }

        // Copy inherited accessors from ancestor documents
        for(DocumentMeta<? super T> ancestor : ancestors) {
            MapUtils.putAbsent(getters, ancestor.getters());
            MapUtils.putAbsent(setters, ancestor.setters());
        }

        return new DocumentMeta<>(type, ancestors, bestBaseClass(type), getters, setters);
    }

    private static String serializedName(Member member) {
        if(member instanceof AnnotatedElement) {
            SerializedName nameAnnot = ((AnnotatedElement) member).getAnnotation(SerializedName.class);
            if(nameAnnot != null) return nameAnnot.value();
        }
        return member.getName();
    }

    private static @Nullable Type getterType(Method method) {
        if(method.getGenericParameterTypes().length == 0 && method.getGenericReturnType() != Void.TYPE) {
            return method.getGenericReturnType();
        }
        return null;
    }

    private static @Nullable Type setterType(Method method) {
        if(method.getGenericParameterTypes().length == 1) {
            return method.getGenericParameterTypes()[0];
        }
        return null;
    }

    private void registerMethod(Map<String, Getter> getters, Map<String, Setter> setters, Method method) {
        if(Modifier.isStatic(method.getModifiers())) return;

        final String name = serializedName(method);
        boolean accessor = false;

        if(getterType(method) != null) {
            accessor = true;
            if(!getters.containsKey(name)) {
                if(logger.isLoggable(Level.FINE)) {
                    logger.fine("  " + name + " -- get --> " + method);
                }
                getters.put(name, new GetterMethod(this, method));
            }
        }

        if(setterType(method) != null) {
            accessor = true;
            if(!setters.containsKey(name)) {
                if(logger.isLoggable(Level.FINE)) {
                    logger.fine("  " + name + " -- set --> " + method);
                }
                setters.put(name, new SetterMethod(this, method));
            }
        }

        if(!accessor) {
            throw new SerializationException("Serialized method " + method + " is not a valid getter or setter");
        }
    }

    private void registerField(Map<String, Getter> getters, Map<String, Setter> setters, Field field) {
        if(Modifier.isTransient(field.getModifiers()) ||
           Modifier.isStatic(field.getModifiers()) ||
           field.isSynthetic() ||
           field.isEnumConstant()) return;

        final String name = serializedName(field);
        final boolean gettable = !getters.containsKey(name);
        final boolean settable = !setters.containsKey(name);

        if(gettable || settable) {
            if(logger.isLoggable(Level.FINE)) {
                String access;
                if(gettable && settable) {
                    access = "get/set";
                } else if(gettable) {
                    access = "get";
                } else {
                    access = "set";
                }
                logger.fine("  " + name + " -- " + access + " --> " + field);
            }

            if(gettable) {
                getters.put(name, new FieldGetter(this, field));
            }

            if(settable) {
                setters.put(name, new FieldSetter(this, field));
            }
        }
    }

    // TODO: This could could be done in a more general way i.e. search
    // the registry for the best existing implementation to inherit from.
    public Class<? extends Document> bestBaseClass(Class<? extends Document> type) {
        if(PlayerId.class.isAssignableFrom(type)) {
            return SimplePlayerId.class;
        } else if(UserId.class.isAssignableFrom(type)) {
            return SimpleUserId.class;
        } else if(DeletableModel.class.isAssignableFrom(type)) {
            return BasicDeletableModel.class;
        } else if(Model.class.isAssignableFrom(type)) {
            return BasicModel.class;
        } else {
            return BasicDocument.class;
        }
    }
}