/*
 * Copyright 2016 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.gradle.model.internal.manage.binding;

import com.google.common.base.Equivalence.Wrapper;
import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.base.Objects;
import com.google.common.base.Predicate;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.*;
import com.google.common.util.concurrent.UncheckedExecutionException;
import org.gradle.api.Named;
import org.gradle.internal.Cast;
import org.gradle.internal.UncheckedException;
import org.gradle.model.Managed;
import org.gradle.model.Unmanaged;
import org.gradle.model.internal.manage.schema.*;
import org.gradle.model.internal.manage.schema.extract.ModelSchemaUtils;
import org.gradle.model.internal.manage.schema.extract.PropertyAccessorType;
import org.gradle.model.internal.method.WeaklyTypeReferencingMethod;
import org.gradle.model.internal.type.ModelType;
import org.gradle.model.internal.type.ModelTypes;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.*;
import java.util.concurrent.ExecutionException;

import static org.gradle.internal.reflect.Methods.DESCRIPTOR_EQUIVALENCE;
import static org.gradle.internal.reflect.Methods.SIGNATURE_EQUIVALENCE;
import static org.gradle.model.internal.manage.schema.extract.ModelSchemaUtils.walkTypeHierarchy;
import static org.gradle.model.internal.manage.schema.extract.PropertyAccessorType.*;

public class DefaultStructBindingsStore implements StructBindingsStore {
    private final LoadingCache<CacheKey, StructBindings<?>> bindings = CacheBuilder.newBuilder()
        .weakValues()
        .build(new CacheLoader<CacheKey, StructBindings<?>>() {
            @Override
            public StructBindings<?> load(CacheKey key) throws Exception {
                return extract(key.publicType, key.viewTypes, key.delegateType);
            }
        });

    private final ModelSchemaStore schemaStore;

    public DefaultStructBindingsStore(ModelSchemaStore schemaStore) {
        this.schemaStore = schemaStore;
    }

    @Override
    public <T> StructBindings<T> getBindings(ModelType<T> publicType) {
        return getBindings(publicType, Collections.<ModelType<?>>emptySet(), null);
    }

    @Override
    public <T> StructBindings<T> getBindings(ModelType<T> publicType, Iterable<? extends ModelType<?>> internalViewTypes, ModelType<?> delegateType) {
        try {
            return Cast.uncheckedCast(bindings.get(new CacheKey(publicType, internalViewTypes, delegateType)));
        } catch (ExecutionException e) {
            throw UncheckedException.throwAsUncheckedException(e);
        } catch (UncheckedExecutionException e) {
            throw UncheckedException.throwAsUncheckedException(e.getCause());
        }
    }

    <T, D> StructBindings<T> extract(ModelType<T> publicType, Iterable<? extends ModelType<?>> internalViewTypes, ModelType<D> delegateType) {
        if (delegateType != null && Modifier.isAbstract(delegateType.getConcreteClass().getModifiers())) {
            throw new InvalidManagedTypeException(String.format("Type '%s' is not a valid managed type: delegate type must be null or a non-abstract type instead of '%s'.",
                publicType.getDisplayName(), delegateType.getDisplayName()));
        }

        Set<ModelType<?>> implementedViews = collectImplementedViews(publicType, internalViewTypes, delegateType);
        StructSchema<T> publicSchema = getStructSchema(publicType);
        Iterable<StructSchema<?>> declaredViewSchemas = getStructSchemas(Iterables.concat(Collections.singleton(publicType), internalViewTypes));
        Iterable<StructSchema<?>> implementedSchemas = getStructSchemas(implementedViews);
        StructSchema<D> delegateSchema = delegateType == null ? null : getStructSchema(delegateType);

        StructBindingExtractionContext<T> extractionContext = new StructBindingExtractionContext<T>(publicSchema, implementedSchemas, delegateSchema);

        if (!(publicSchema instanceof RuleSourceSchema)) {
            validateTypeHierarchy(extractionContext, publicType);
            for (ModelType<?> internalViewType : internalViewTypes) {
                validateTypeHierarchy(extractionContext, internalViewType);
            }
        }

        Map<String, Multimap<PropertyAccessorType, StructMethodBinding>> propertyBindings = Maps.newTreeMap();
        Set<StructMethodBinding> methodBindings = collectMethodBindings(extractionContext, propertyBindings);
        ImmutableSortedMap<String, ManagedProperty<?>> managedProperties = collectManagedProperties(extractionContext, propertyBindings);

        if (extractionContext.problems.hasProblems()) {
            throw new InvalidManagedTypeException(extractionContext.problems.format());
        }

        return new DefaultStructBindings<T>(
            publicSchema, declaredViewSchemas, implementedSchemas, delegateSchema,
            managedProperties, methodBindings
        );
    }

    private static <T> void validateTypeHierarchy(final StructBindingValidationProblemCollector problems, ModelType<T> type) {
        walkTypeHierarchy(type.getConcreteClass(), new ModelSchemaUtils.TypeVisitor<T>() {
            @Override
            public void visitType(Class<? super T> type) {
                if (type.isAnnotationPresent(Managed.class)) {
                    validateManagedType(problems, type);
                }
                validateType(problems, type);
            }
        });
    }

    private static void validateManagedType(StructBindingValidationProblemCollector problems, Class<?> typeClass) {
        if (!typeClass.isInterface() && !Modifier.isAbstract(typeClass.getModifiers())) {
            problems.add("Must be defined as an interface or an abstract class.");
        }

        if (typeClass.getTypeParameters().length > 0) {
            problems.add("Cannot be a parameterized type.");
        }
    }

    private static void validateType(StructBindingValidationProblemCollector problems, Class<?> typeClass) {
        Constructor<?> customConstructor = findCustomConstructor(typeClass);
        if (customConstructor != null) {
            problems.add(customConstructor, "Custom constructors are not supported.");
        }

        ensureNoInstanceScopedFields(problems, typeClass);

        // sort for determinism
        Method[] methods = typeClass.getDeclaredMethods();
        Arrays.sort(methods, Ordering.usingToString());

        ensureNoProtectedOrPrivateMethods(problems, methods);
        ensureNoDefaultMethods(problems, typeClass, methods);
    }

    private static Constructor<?> findCustomConstructor(Class<?> typeClass) {
        Constructor<?>[] constructors = typeClass.getConstructors();
        for (Constructor<?> constructor : constructors) {
            if (constructor.getParameterTypes().length > 0) {
                return constructor;
            }
        }
        return null;
    }

    private static void ensureNoInstanceScopedFields(StructBindingValidationProblemCollector problems, Class<?> typeClass) {
        List<Field> declaredFields = Arrays.asList(typeClass.getDeclaredFields());
        for (Field field : declaredFields) {
            int fieldModifiers = field.getModifiers();
            if (!field.isSynthetic() && !(Modifier.isStatic(fieldModifiers) && Modifier.isFinal(fieldModifiers))) {
                problems.add(field, "Fields must be static final.");
            }
        }
    }

    private static void ensureNoProtectedOrPrivateMethods(StructBindingValidationProblemCollector problems, Method[] declaredMethods) {
        for (Method declaredMethod : declaredMethods) {
            int modifiers = declaredMethod.getModifiers();
            if (!declaredMethod.isSynthetic() && !Modifier.isPublic(modifiers) && !Modifier.isStatic(modifiers)) {
                problems.add(declaredMethod, "Protected and private methods are not supported.");
            }
        }
    }

    private static void ensureNoDefaultMethods(StructBindingValidationProblemCollector problems, Class<?> typeClass, Method[] declaredMethods) {
        if (!typeClass.isInterface()) {
            return;
        }
        for (Method declaredMethod : declaredMethods) {
            if (isDefaultInterfaceMethod(declaredMethod) && PropertyAccessorType.of(declaredMethod) == null) {
                problems.add(declaredMethod, "Default interface methods are only supported for getters and setters.");
            }
        }
    }

    // Copied from Method.isDefault()
    private static boolean isDefaultInterfaceMethod(Method method) {
        // Default methods are public non-abstract instance methods declared in an interface.
        return (method.getModifiers() & (Modifier.ABSTRACT | Modifier.PUBLIC | Modifier.STATIC)) == Modifier.PUBLIC;
    }

    private <T> ImmutableSortedMap<String, ManagedProperty<?>> collectManagedProperties(StructBindingExtractionContext<T> extractionContext, Map<String, Multimap<PropertyAccessorType, StructMethodBinding>> propertyBindings) {
        ImmutableSortedMap.Builder<String, ManagedProperty<?>> managedPropertiesBuilder = ImmutableSortedMap.naturalOrder();
        for (Map.Entry<String, Multimap<PropertyAccessorType, StructMethodBinding>> propertyEntry : propertyBindings.entrySet()) {
            String propertyName = propertyEntry.getKey();
            Multimap<PropertyAccessorType, StructMethodBinding> accessorBindings = propertyEntry.getValue();

            if (isManagedProperty(extractionContext, propertyName, accessorBindings)) {
                if (hasSetter(accessorBindings.keySet()) && !hasGetter(accessorBindings.keySet())) {
                    extractionContext.add(propertyName, "it must both have an abstract getter and a setter");
                    continue;
                }

                ModelType<?> propertyType = determineManagedPropertyType(extractionContext, propertyName, accessorBindings);
                ModelSchema<?> propertySchema = schemaStore.getSchema(propertyType);
                managedPropertiesBuilder.put(propertyName, createManagedProperty(extractionContext, propertyName, propertySchema, accessorBindings));
            }
        }
        return managedPropertiesBuilder.build();
    }

    private static boolean isManagedProperty(StructBindingExtractionContext<?> extractionContext, String propertyName, Multimap<PropertyAccessorType, StructMethodBinding> accessorBindings) {
        Boolean managed = null;
        for (Map.Entry<PropertyAccessorType, Collection<StructMethodBinding>> accessorEntry : accessorBindings.asMap().entrySet()) {
            Collection<StructMethodBinding> bindings = accessorEntry.getValue();
            boolean managedPropertyAccessor = isManagedPropertyAccessor(extractionContext, propertyName, bindings);
            if (managed == null) {
                managed = managedPropertyAccessor;
            } else if (managed != managedPropertyAccessor) {
                extractionContext.add(propertyName, "it must have either only abstract accessor methods or only implemented accessor methods");
                managed = false;
                break;
            }
        }
        assert managed != null;
        return managed;
    }

    private static boolean isManagedPropertyAccessor(StructBindingExtractionContext<?> extractionContext, String propertyName, Collection<StructMethodBinding> bindings) {
        Set<WeaklyTypeReferencingMethod<?, ?>> implMethods = Sets.newLinkedHashSet();
        for (StructMethodBinding binding : bindings) {
            if (binding instanceof StructMethodImplementationBinding) {
                implMethods.add(((StructMethodImplementationBinding) binding).getImplementorMethod());
            }
        }
        switch (implMethods.size()) {
            case 0:
                return true;
            case 1:
                return false;
            default:
                extractionContext.add(propertyName, String.format("it has multiple implementations for accessor method: %s",
                    Joiner.on(", ").join(implMethods)));
                return false;
        }
    }

    private static ModelType<?> determineManagedPropertyType(StructBindingExtractionContext<?> extractionContext, String propertyName, Multimap<PropertyAccessorType, StructMethodBinding> accessorBindings) {
        Collection<StructMethodBinding> isGetter = accessorBindings.get(IS_GETTER);
        for (StructMethodBinding isGetterBinding : isGetter) {
            if (!((ManagedPropertyMethodBinding) isGetterBinding).getDeclaredPropertyType().equals(ModelType.of(Boolean.TYPE))) {
                WeaklyTypeReferencingMethod<?, ?> isGetterMethod = isGetterBinding.getViewMethod();
                extractionContext.add(isGetterMethod, String.format("it should either return 'boolean', or its name should be '%s()'",
                    "get" + isGetterMethod.getName().substring(2)));
            }
        }
        Set<ModelType<?>> potentialPropertyTypes = Sets.newLinkedHashSet();
        for (StructMethodBinding binding : accessorBindings.values()) {
            if (binding.getAccessorType() == SETTER) {
                continue;
            }
            ManagedPropertyMethodBinding propertyBinding = (ManagedPropertyMethodBinding) binding;
            potentialPropertyTypes.add(propertyBinding.getDeclaredPropertyType());
        }
        Collection<ModelType<?>> convergingPropertyTypes = findConvergingTypes(potentialPropertyTypes);
        if (convergingPropertyTypes.size() != 1) {
            extractionContext.add(propertyName, String.format("it must have a consistent type, but it's defined as %s",
                Joiner.on(", ").join(ModelTypes.getDisplayNames(convergingPropertyTypes))));
            return convergingPropertyTypes.iterator().next();
        }
        ModelType<?> propertyType = Iterables.getOnlyElement(convergingPropertyTypes);

        for (StructMethodBinding setterBinding : accessorBindings.get(SETTER)) {
            ManagedPropertyMethodBinding propertySetterBinding = (ManagedPropertyMethodBinding) setterBinding;
            ModelType<?> declaredSetterType = propertySetterBinding.getDeclaredPropertyType();
            if (!declaredSetterType.equals(propertyType)) {
                extractionContext.add(setterBinding.getViewMethod(), String.format("it should take parameter with type '%s'",
                    propertyType.getDisplayName()));
            }
        }
        return propertyType;
    }

    private static <T, D> Set<ModelType<?>> collectImplementedViews(ModelType<T> publicType, Iterable<? extends ModelType<?>> internalViewTypes, ModelType<D> delegateType) {
        final Set<ModelType<?>> viewsToImplement = Sets.newLinkedHashSet();
        viewsToImplement.add(publicType);
        Iterables.addAll(viewsToImplement, internalViewTypes);
        // TODO:LPTR This should be removed once BinaryContainer is a ModelMap
        // We need to also implement all the interfaces of the delegate type because otherwise
        // BinaryContainer won't recognize managed binaries as BinarySpecInternal
        if (delegateType != null) {
            ModelSchemaUtils.walkTypeHierarchy(delegateType.getConcreteClass(), new ModelSchemaUtils.TypeVisitor<D>() {
                @Override
                public void visitType(Class<? super D> type) {
                    if (type.isInterface()) {
                        viewsToImplement.add(ModelType.of(type));
                    }
                }
            });
        }
        return ModelTypes.collectHierarchy(viewsToImplement);
    }

    private static <T> Set<StructMethodBinding> collectMethodBindings(StructBindingExtractionContext<T> extractionContext, Map<String, Multimap<PropertyAccessorType, StructMethodBinding>> propertyBindings) {
        Collection<WeaklyTypeReferencingMethod<?, ?>> implementedMethods = collectImplementedMethods(extractionContext.getImplementedSchemas());
        Map<Wrapper<Method>, WeaklyTypeReferencingMethod<?, ?>> publicViewImplMethods = collectPublicViewImplMethods(extractionContext.getPublicSchema());
        Map<Wrapper<Method>, WeaklyTypeReferencingMethod<?, ?>> delegateMethods = collectDelegateMethods(extractionContext.getDelegateSchema());

        ImmutableSet.Builder<StructMethodBinding> methodBindingsBuilder = ImmutableSet.builder();
        for (WeaklyTypeReferencingMethod<?, ?> weakImplementedMethod : implementedMethods) {
            Method implementedMethod = weakImplementedMethod.getMethod();
            PropertyAccessorType accessorType = PropertyAccessorType.of(implementedMethod);

            Wrapper<Method> methodKey = SIGNATURE_EQUIVALENCE.wrap(implementedMethod);
            WeaklyTypeReferencingMethod<?, ?> weakDelegateImplMethod = delegateMethods.get(methodKey);
            WeaklyTypeReferencingMethod<?, ?> weakPublicImplMethod = publicViewImplMethods.get(methodKey);
            if (weakDelegateImplMethod != null && weakPublicImplMethod != null) {
                extractionContext.add(weakImplementedMethod, String.format("it is both implemented by the view '%s' and the delegate type '%s'",
                    extractionContext.getPublicSchema().getType().getDisplayName(),
                    extractionContext.getDelegateSchema().getType().getDisplayName()));
            }

            String propertyName = accessorType == null ? null : accessorType.propertyNameFor(implementedMethod);

            StructMethodBinding binding;
            if (!Modifier.isAbstract(implementedMethod.getModifiers())) {
                binding = new DirectMethodBinding(weakImplementedMethod, accessorType);
            } else if (weakPublicImplMethod != null) {
                binding = new BridgeMethodBinding(weakImplementedMethod, weakPublicImplMethod, accessorType);
            } else if (weakDelegateImplMethod != null) {
                binding = new DelegateMethodBinding(weakImplementedMethod, weakDelegateImplMethod, accessorType);
            } else if (propertyName != null) {
                binding = new ManagedPropertyMethodBinding(weakImplementedMethod, propertyName, accessorType);
            } else {
                handleNoMethodImplementation(extractionContext, weakImplementedMethod);
                continue;
            }
            methodBindingsBuilder.add(binding);

            if (accessorType != null) {
                Multimap<PropertyAccessorType, StructMethodBinding> accessorBindings = propertyBindings.get(propertyName);
                if (accessorBindings == null) {
                    accessorBindings = ArrayListMultimap.create();
                    propertyBindings.put(propertyName, accessorBindings);
                }
                accessorBindings.put(accessorType, binding);
            }
        }
        return methodBindingsBuilder.build();
    }

    private static void handleNoMethodImplementation(StructBindingValidationProblemCollector problems, WeaklyTypeReferencingMethod<?, ?> method) {
        String methodName = method.getName();
        PropertyAccessorType accessorType = PropertyAccessorType.fromName(methodName);
        if (accessorType != null) {
            switch (accessorType) {
                case GET_GETTER:
                case IS_GETTER:
                    if (!PropertyAccessorType.takesNoParameter(method.getMethod())) {
                        problems.add(method, "property accessor", "getter method must not take parameters");
                    }
                    break;
                case SETTER:
                    if (!hasVoidReturnType(method.getMethod())) {
                        problems.add(method, "property accessor", "setter method must have void return type");
                    }
                    if (!takesSingleParameter(method.getMethod())) {
                        problems.add(method, "property accessor", "setter method must take exactly one parameter");
                    }
                    break;
                default:
                    throw new AssertionError();
            }
        } else {
            problems.add(method, "managed type", "it must have an implementation");
        }
    }

    private static Map<Wrapper<Method>, WeaklyTypeReferencingMethod<?, ?>> collectDelegateMethods(StructSchema<?> delegateSchema) {
        return delegateSchema == null ? Collections.<Wrapper<Method>, WeaklyTypeReferencingMethod<?, ?>>emptyMap() : indexBySignature(delegateSchema.getAllMethods());
    }

    private static <T> Map<Wrapper<Method>, WeaklyTypeReferencingMethod<?, ?>> collectPublicViewImplMethods(StructSchema<T> publicSchema) {
        return indexBySignature(
            Sets.filter(
                publicSchema.getAllMethods(),
                new Predicate<WeaklyTypeReferencingMethod<?, ?>>() {
                    @Override
                    public boolean apply(WeaklyTypeReferencingMethod<?, ?> weakMethod) {
                        return !Modifier.isAbstract(weakMethod.getModifiers());
                    }
                }
            )
        );
    }

    private static ImmutableMap<Wrapper<Method>, WeaklyTypeReferencingMethod<?, ?>> indexBySignature(Iterable<WeaklyTypeReferencingMethod<?, ?>> methods) {
        return Maps.uniqueIndex(methods, new Function<WeaklyTypeReferencingMethod<?, ?>, Wrapper<Method>>() {
            @Override
            public Wrapper<Method> apply(WeaklyTypeReferencingMethod<?, ?> weakMethod) {
                return SIGNATURE_EQUIVALENCE.wrap(weakMethod.getMethod());
            }
        });
    }

    private static Collection<WeaklyTypeReferencingMethod<?, ?>> collectImplementedMethods(Iterable<StructSchema<?>> implementedSchemas) {
        Map<Wrapper<Method>, WeaklyTypeReferencingMethod<?, ?>> implementedMethodsBuilder = Maps.newLinkedHashMap();
        for (StructSchema<?> implementedSchema : implementedSchemas) {
            for (WeaklyTypeReferencingMethod<?, ?> viewMethod : implementedSchema.getAllMethods()) {
                implementedMethodsBuilder.put(DESCRIPTOR_EQUIVALENCE.wrap(viewMethod.getMethod()), viewMethod);
            }
        }
        return implementedMethodsBuilder.values();
    }

    private static <T> ManagedProperty<T> createManagedProperty(StructBindingExtractionContext<?> extractionContext, String propertyName, ModelSchema<T> propertySchema, Multimap<PropertyAccessorType, StructMethodBinding> accessors) {
        boolean writable = accessors.containsKey(SETTER);
        boolean declaredAsUnmanaged = isDeclaredAsHavingUnmanagedType(accessors.get(GET_GETTER))
            || isDeclaredAsHavingUnmanagedType(accessors.get(IS_GETTER));
        boolean internal = !extractionContext.getPublicSchema().hasProperty(propertyName);

        validateManagedProperty(extractionContext, propertyName, propertySchema, writable, declaredAsUnmanaged);

        return new ManagedProperty<T>(propertyName, propertySchema.getType(), writable, declaredAsUnmanaged, internal);
    }

    private static void validateManagedProperty(StructBindingExtractionContext<?> extractionContext, String propertyName, ModelSchema<?> propertySchema, boolean writable, boolean isDeclaredAsHavingUnmanagedType) {
        if (propertyName.equals("name") && Named.class.isAssignableFrom(extractionContext.getPublicSchema().getType().getRawClass())) {
            if (writable) {
                extractionContext.add(propertyName, String.format("it must not have a setter, because the type implements '%s'", Named.class.getName()));
            }
            return;
        }

        // Only managed implementation and value types are allowed as a managed property type unless marked with @Unmanaged
        boolean isAllowedPropertyTypeOfManagedType = propertySchema instanceof ManagedImplSchema
            || propertySchema instanceof ScalarValueSchema;

        ModelType<?> propertyType = propertySchema.getType();

        if (isAllowedPropertyTypeOfManagedType && isDeclaredAsHavingUnmanagedType) {
            extractionContext.add(propertyName, String.format("it is marked as @Unmanaged, but is of @Managed type '%s'; please remove the @Managed annotation",
                    propertyType.getDisplayName()
            ));
        }

        if (!writable && isDeclaredAsHavingUnmanagedType) {
            extractionContext.add(propertyName, "it must not be read only, because it is marked as @Unmanaged");
        }

        if (!(extractionContext.getPublicSchema() instanceof RuleSourceSchema)) {
            if (propertySchema instanceof CollectionSchema) {
                if (!(propertySchema instanceof ScalarCollectionSchema) && writable) {
                    extractionContext.add(propertyName, String.format("it cannot have a setter (%s properties must be read only)",
                        propertyType.getRawClass().getSimpleName()));
                }
            }
        }
    }

    private static boolean isDeclaredAsHavingUnmanagedType(Collection<StructMethodBinding> accessorBindings) {
        for (StructMethodBinding accessorBinding : accessorBindings) {
            if (accessorBinding.getViewMethod().getMethod().isAnnotationPresent(Unmanaged.class)) {
                return true;
            }
        }
        return false;
    }

    private <T> Iterable<StructSchema<? extends T>> getStructSchemas(Iterable<? extends ModelType<? extends T>> types) {
        return Iterables.transform(types, new Function<ModelType<? extends T>, StructSchema<? extends T>>() {
            @Override
            public StructSchema<? extends T> apply(ModelType<? extends T> type) {
                return  getStructSchema(type);
            }
        });
    }

    private <T> StructSchema<T> getStructSchema(ModelType<T> type) {
        ModelSchema<T> schema = schemaStore.getSchema(type);
        if (!(schema instanceof StructSchema)) {
            throw new IllegalArgumentException(String.format("Type '%s' is not a struct.", type.getDisplayName()));
        }
        return Cast.uncheckedCast(schema);
    }

    private static class CacheKey {
        private final ModelType<?> publicType;
        private final Set<ModelType<?>> viewTypes;
        private final ModelType<?> delegateType;

        public CacheKey(ModelType<?> publicType, Iterable<? extends ModelType<?>> viewTypes, ModelType<?> delegateType) {
            this.publicType = publicType;
            this.viewTypes = ImmutableSet.copyOf(viewTypes);
            this.delegateType = delegateType;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }
            CacheKey cacheKey = (CacheKey) o;
            return Objects.equal(publicType, cacheKey.publicType)
                && Objects.equal(viewTypes, cacheKey.viewTypes)
                && Objects.equal(delegateType, cacheKey.delegateType);
        }

        @Override
        public int hashCode() {
            return Objects.hashCode(publicType, viewTypes, delegateType);
        }
    }

    /**
     * Finds the types in the given collection that cannot be assigned from any other type in the collection.
     */
    static Collection<ModelType<?>> findConvergingTypes(Collection<ModelType<?>> allTypes) {
        if (allTypes.size() == 0) {
            throw new IllegalArgumentException("No types given");
        }
        if (allTypes.size() == 1) {
            return allTypes;
        }

        Set<ModelType<?>> typesToCheck = Sets.newLinkedHashSet(allTypes);
        Set<ModelType<?>> convergingTypes = Sets.newLinkedHashSet(allTypes);

        while (!typesToCheck.isEmpty()) {
            Iterator<ModelType<?>> iTypeToCheck = typesToCheck.iterator();
            ModelType<?> typeToCheck = iTypeToCheck.next();
            iTypeToCheck.remove();

            Iterator<ModelType<?>> iRemainingType = convergingTypes.iterator();
            while (iRemainingType.hasNext()) {
                ModelType<?> remainingType = iRemainingType.next();
                if (!remainingType.equals(typeToCheck) && remainingType.isAssignableFrom(typeToCheck)) {
                    iRemainingType.remove();
                    typesToCheck.remove(remainingType);
                }
            }
        }

        return convergingTypes;
    }
}
