/*
 * Copyright 2018 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.internal.metaobject;

import groovy.lang.GroovyObject;
import groovy.lang.GroovySystem;
import groovy.lang.MetaBeanProperty;
import groovy.lang.MetaClass;
import groovy.lang.MetaClassImpl;
import groovy.lang.MetaMethod;
import groovy.lang.MetaProperty;
import groovy.lang.MissingMethodException;
import groovy.lang.MissingPropertyException;
import org.codehaus.groovy.runtime.InvokerInvocationException;
import org.codehaus.groovy.runtime.MetaClassHelper;
import org.codehaus.groovy.runtime.metaclass.MultipleSetterProperty;
import org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation;
import org.gradle.api.internal.DynamicObjectAware;
import org.gradle.api.internal.coerce.MethodArgumentsTransformer;
import org.gradle.api.internal.coerce.PropertySetTransformer;
import org.gradle.api.internal.coerce.StringToEnumTransformer;
import org.gradle.internal.UncheckedException;
import org.gradle.internal.reflect.JavaPropertyReflectionUtil;

import javax.annotation.Nullable;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * A {@link DynamicObject} which uses groovy reflection to provide access to the properties and methods of a bean.
 *
 * <p>Uses some deep hacks to avoid some expensive reflections and the use of exceptions when a particular property or method cannot be found,
 * for example, when a decorated object is used as the delegate of a configuration closure. Also uses some hacks to insert some customised type
 * coercion and error reporting. Enjoy.
 */
public class BeanDynamicObject extends AbstractDynamicObject {
    private static final Method META_PROP_METHOD;
    private static final Field MISSING_PROPERTY_GET_METHOD;
    private static final Field MISSING_PROPERTY_SET_METHOD;
    private static final Field MISSING_METHOD_METHOD;
    private final Object bean;
    private final boolean includeProperties;
    private final MetaClassAdapter delegate;
    private final boolean implementsMissing;
    @Nullable
    private final Class<?> publicType;

    private final MethodArgumentsTransformer argsTransformer;
    private final PropertySetTransformer propertySetTransformer;

    private BeanDynamicObject withNoProperties;
    private BeanDynamicObject withNoImplementsMissing;

    static {
        try {
            META_PROP_METHOD = MetaClassImpl.class.getDeclaredMethod("getMetaProperty", String.class, boolean.class);
            META_PROP_METHOD.setAccessible(true);
            MISSING_PROPERTY_GET_METHOD = MetaClassImpl.class.getDeclaredField("propertyMissingGet");
            MISSING_PROPERTY_GET_METHOD.setAccessible(true);
            MISSING_PROPERTY_SET_METHOD = MetaClassImpl.class.getDeclaredField("propertyMissingSet");
            MISSING_PROPERTY_SET_METHOD.setAccessible(true);
            MISSING_METHOD_METHOD = MetaClassImpl.class.getDeclaredField("methodMissing");
            MISSING_METHOD_METHOD.setAccessible(true);
        } catch (Exception e) {
            throw UncheckedException.throwAsUncheckedException(e);
        }
    }

    public BeanDynamicObject(Object bean) {
        this(bean, null, true, true, StringToEnumTransformer.INSTANCE, StringToEnumTransformer.INSTANCE);
    }

    public BeanDynamicObject(Object bean, @Nullable Class<?> publicType) {
        this(bean, publicType, true, true, StringToEnumTransformer.INSTANCE, StringToEnumTransformer.INSTANCE);
    }

    BeanDynamicObject(Object bean, @Nullable Class<?> publicType, boolean includeProperties, boolean implementsMissing, PropertySetTransformer propertySetTransformer, MethodArgumentsTransformer methodArgumentsTransformer) {
        if (bean == null) {
            throw new IllegalArgumentException("Value is null");
        }
        this.bean = bean;
        this.publicType = publicType;
        this.includeProperties = includeProperties;
        this.implementsMissing = implementsMissing;
        this.propertySetTransformer = propertySetTransformer;
        this.argsTransformer = methodArgumentsTransformer;
        this.delegate = determineDelegate(bean);
    }

    public MetaClassAdapter determineDelegate(Object bean) {
        if (bean instanceof Class) {
            return new ClassAdapter((Class<?>) bean);
        } else if (bean instanceof Map) {
            return new MapAdapter();
        } else if (bean instanceof DynamicObject || bean instanceof DynamicObjectAware || !(bean instanceof GroovyObject)) {
            return new MetaClassAdapter();
        }
        return new GroovyObjectAdapter();
    }

    public BeanDynamicObject withNoProperties() {
        if (!includeProperties) {
            return this;
        }
        if (withNoProperties == null) {
            withNoProperties = new BeanDynamicObject(bean, publicType, false, implementsMissing, propertySetTransformer, argsTransformer);
        }
        return withNoProperties;
    }

    public BeanDynamicObject withNotImplementsMissing() {
        if (!implementsMissing) {
            return this;
        }
        if (withNoImplementsMissing == null) {
            withNoImplementsMissing = new BeanDynamicObject(bean, publicType, includeProperties, false, propertySetTransformer, argsTransformer);
        }
        return withNoImplementsMissing;
    }

    @Override
    public String getDisplayName() {
        return bean.toString();
    }

    @Nullable
    @Override
    public Class<?> getPublicType() {
        return publicType != null ? publicType : bean.getClass();
    }

    @Override
    public boolean hasUsefulDisplayName() {
        return !JavaPropertyReflectionUtil.hasDefaultToString(bean);
    }

    private MetaClass getMetaClass() {
        if (bean instanceof GroovyObject) {
            return ((GroovyObject) bean).getMetaClass();
        } else {
            return GroovySystem.getMetaClassRegistry().getMetaClass(bean.getClass());
        }
    }

    @Override
    public boolean hasProperty(String name) {
        return delegate.hasProperty(name);
    }

    @Override
    public DynamicInvokeResult tryGetProperty(String name) {
        return delegate.getProperty(name);
    }

    @Override
    public DynamicInvokeResult trySetProperty(String name, Object value) {
        return delegate.setProperty(name, value);
    }

    @Override
    public Map<String, ?> getProperties() {
        return delegate.getProperties();
    }

    @Override
    public boolean hasMethod(String name, Object... arguments) {
        return delegate.hasMethod(name, arguments);
    }

    @Override
    public DynamicInvokeResult tryInvokeMethod(String name, Object... arguments) {
        return delegate.invokeMethod(name, arguments);
    }

    private class MetaClassAdapter {
        protected String getDisplayName() {
            return BeanDynamicObject.this.getDisplayName();
        }

        public boolean hasProperty(String name) {
            if (!includeProperties) {
                return false;
            }
            if (lookupProperty(getMetaClass(), name) != null) {
                return true;
            }
            if (bean instanceof PropertyMixIn) {
                PropertyMixIn propertyMixIn = (PropertyMixIn) bean;
                return propertyMixIn.getAdditionalProperties().hasProperty(name);
            }
            return false;
        }

        public DynamicInvokeResult getProperty(String name) {
            if (!includeProperties) {
                return DynamicInvokeResult.notFound();
            }

            MetaClass metaClass = getMetaClass();

            // First look for a property known to the meta-class
            MetaProperty property = lookupProperty(metaClass, name);
            if (property != null) {
                if (property instanceof MetaBeanProperty && ((MetaBeanProperty) property).getGetter() == null) {
                    throw getWriteOnlyProperty(name);
                }

                try {
                    return DynamicInvokeResult.found(property.getProperty(bean));
                } catch (InvokerInvocationException e) {
                    if (e.getCause() instanceof RuntimeException) {
                        throw (RuntimeException) e.getCause();
                    }
                    throw e;
                }
            }

            if (bean instanceof PropertyMixIn) {
                PropertyMixIn propertyMixIn = (PropertyMixIn) bean;
                return propertyMixIn.getAdditionalProperties().tryGetProperty(name);
                // Do not check for opaque properties when implementing PropertyMixIn, as this is expensive
            }

            if (!implementsMissing) {
                return DynamicInvokeResult.notFound();
            }

            // Fall back to propertyMissing, if available
            MetaMethod propertyMissing = findGetPropertyMissingMethod(metaClass);
            if (propertyMissing != null) {
                try {
                    return DynamicInvokeResult.found(propertyMissing.invoke(bean, new Object[]{name}));
                } catch (MissingPropertyException e) {
                    if (!name.equals(e.getProperty())) {
                        throw e;
                    }
                    // Else, ignore
                }
            }

            return getOpaqueProperty(name);
        }

        protected DynamicInvokeResult getOpaqueProperty(String name) {
            return DynamicInvokeResult.notFound();
        }

        @Nullable
        private MetaMethod findGetPropertyMissingMethod(MetaClass metaClass) {
            if (metaClass instanceof MetaClassImpl) {
                // Reach into meta class to avoid lookup
                try {
                    return (MetaMethod) MISSING_PROPERTY_GET_METHOD.get(metaClass);
                } catch (IllegalAccessException e) {
                    throw UncheckedException.throwAsUncheckedException(e);
                }
            }

            // Query the declared methods of the meta class
            for (MetaMethod method : metaClass.getMethods()) {
                if (method.getName().equals("propertyMissing") && method.getParameterTypes().length == 1) {
                    return method;
                }
            }
            return null;
        }

        @Nullable
        private MetaMethod findSetPropertyMissingMethod(MetaClass metaClass) {
            if (metaClass instanceof MetaClassImpl) {
                // Reach into meta class to avoid lookup
                try {
                    return (MetaMethod) MISSING_PROPERTY_SET_METHOD.get(metaClass);
                } catch (IllegalAccessException e) {
                    throw UncheckedException.throwAsUncheckedException(e);
                }
            }

            // Query the declared methods of the meta class
            for (MetaMethod method : metaClass.getMethods()) {
                if (method.getName().equals("propertyMissing") && method.getParameterTypes().length == 2) {
                    return method;
                }
            }
            return null;
        }

        @Nullable
        private MetaMethod findMethodMissingMethod(MetaClass metaClass) {
            if (metaClass instanceof MetaClassImpl) {
                // Reach into meta class to avoid lookup
                try {
                    return (MetaMethod) MISSING_METHOD_METHOD.get(metaClass);
                } catch (IllegalAccessException e) {
                    throw UncheckedException.throwAsUncheckedException(e);
                }
            }

            // Query the declared methods of the meta class
            for (MetaMethod method : metaClass.getMethods()) {
                if (method.getName().equals("methodMissing") && method.getParameterTypes().length == 2) {
                    return method;
                }
            }
            return null;
        }

        /*
         * MetaClass.getMetaProperty(name) is very expensive when the property is not known.
         * Instead, we reach into the meta class to call a much more efficient lookup method.
         * Since we do this in a hot code path, we also reuse the argument array used for the
         * reflective call to save memory.
         */
        @Nullable
        protected MetaProperty lookupProperty(MetaClass metaClass, String name) {
            if (metaClass instanceof MetaClassImpl) {
                try {
                    return (MetaProperty) META_PROP_METHOD.invoke(metaClass, name, false);
                } catch (Throwable e) {
                    throw UncheckedException.throwAsUncheckedException(e);
                }
            }

            // Some other meta-class implementation - fall back to the public API
            return metaClass.getMetaProperty(name);
        }

        public DynamicInvokeResult setProperty(final String name, Object value) {
            if (!includeProperties) {
                return DynamicInvokeResult.notFound();
            }

            MetaClass metaClass = getMetaClass();
            MetaProperty property = lookupProperty(metaClass, name);
            if (property != null) {
                if (property instanceof MultipleSetterProperty) {
                    // Invoke the setter method, to pick up type coercion
                    String setterName = MetaProperty.getSetterName(property.getName());
                    DynamicInvokeResult setterResult = invokeMethod(setterName, value);
                    if (setterResult.isFound()) {
                        return DynamicInvokeResult.found();
                    }
                } else {
                    if (property instanceof MetaBeanProperty) {
                        MetaBeanProperty metaBeanProperty = (MetaBeanProperty) property;
                        if (metaBeanProperty.getSetter() == null) {
                            if (metaBeanProperty.getField() == null) {
                                throw setReadOnlyProperty(name);
                            }
                            value = propertySetTransformer.transformValue(metaBeanProperty.getField().getType(), value);
                            metaBeanProperty.getField().setProperty(bean, value);
                        } else {
                            // Coerce the value to the type accepted by the property setter and invoke the setter directly
                            Class setterType = metaBeanProperty.getSetter().getParameterTypes()[0].getTheClass();
                            value = propertySetTransformer.transformValue(setterType, value);
                            value = DefaultTypeTransformation.castToType(value, setterType);
                            metaBeanProperty.getSetter().invoke(bean, new Object[]{value});
                        }
                    } else {
                        // Coerce the value to the property type, if known
                        value = propertySetTransformer.transformValue(property.getType(), value);
                        property.setProperty(bean, value);
                    }
                    return DynamicInvokeResult.found();
                }
            }

            if (bean instanceof PropertyMixIn) {
                PropertyMixIn propertyMixIn = (PropertyMixIn) bean;
                return propertyMixIn.getAdditionalProperties().trySetProperty(name, value);
                // When implementing PropertyMixIn, do not check for opaque properties, as this can be expensive
            }

            if (!implementsMissing) {
                return DynamicInvokeResult.notFound();
            }

            MetaMethod propertyMissingMethod = findSetPropertyMissingMethod(metaClass);
            if (propertyMissingMethod != null) {
                try {
                    propertyMissingMethod.invoke(bean, new Object[]{name, value});
                    return DynamicInvokeResult.found();
                } catch (MissingPropertyException e) {
                    if (!name.equals(e.getProperty())) {
                        throw e;
                    }
                    // Else, ignore
                }
            }

            return setOpaqueProperty(metaClass, name, value);
        }

        protected DynamicInvokeResult setOpaqueProperty(MetaClass metaClass, String name, Object value) {
            return DynamicInvokeResult.notFound();
        }

        public Map<String, ?> getProperties() {
            if (!includeProperties) {
                return Collections.emptyMap();
            }

            Map<String, Object> properties = new HashMap<String, Object>();
            List<MetaProperty> classProperties = getMetaClass().getProperties();
            for (MetaProperty metaProperty : classProperties) {
                if (metaProperty.getName().equals("properties")) {
                    properties.put("properties", properties);
                    continue;
                }
                if (metaProperty instanceof MetaBeanProperty) {
                    MetaBeanProperty beanProperty = (MetaBeanProperty) metaProperty;
                    if (beanProperty.getGetter() == null) {
                        continue;
                    }
                }
                properties.put(metaProperty.getName(), metaProperty.getProperty(bean));
            }
            if (bean instanceof PropertyMixIn) {
                PropertyMixIn propertyMixIn = (PropertyMixIn) bean;
                properties.putAll(propertyMixIn.getAdditionalProperties().getProperties());
            }
            getOpaqueProperties(properties);
            return properties;
        }

        protected void getOpaqueProperties(Map<String, Object> properties) {
        }

        public boolean hasMethod(final String name, final Object... arguments) {
            if (lookupMethod(getMetaClass(), name, inferTypes(arguments)) != null) {
                return true;
            }
            if (bean instanceof MethodMixIn) {
                MethodMixIn methodMixIn = (MethodMixIn) bean;
                return methodMixIn.getAdditionalMethods().hasMethod(name, arguments);
            }
            return false;
        }

        private Class[] inferTypes(Object[] arguments) {
            if (arguments == null || arguments.length == 0) {
                return MetaClassHelper.EMPTY_CLASS_ARRAY;
            }
            Class[] classes = new Class[arguments.length];
            for (int i = 0; i < arguments.length; i++) {
                Object argType = arguments[i];
                if (argType == null) {
                    classes[i] = null;
                } else {
                    classes[i] = argType.getClass();
                }
            }
            return classes;
        }

        public DynamicInvokeResult invokeMethod(String name, Object... arguments) {
            MetaClass metaClass = getMetaClass();
            MetaMethod metaMethod = lookupMethod(metaClass, name, inferTypes(arguments));
            if (metaMethod != null) {
                return DynamicInvokeResult.found(metaMethod.doMethodInvoke(bean, arguments));
            }

            if (argsTransformer.canTransform(arguments)) {
                List<MetaMethod> metaMethods = metaClass.respondsTo(bean, name);
                for (MetaMethod method : metaMethods) {
                    if (method.getParameterTypes().length != arguments.length) {
                        continue;
                    }
                    Object[] transformed = argsTransformer.transform(method.getParameterTypes(), arguments);
                    if (transformed == arguments) {
                        continue;
                    }
                    return DynamicInvokeResult.found(method.doMethodInvoke(bean, transformed));
                }
            }

            if (bean instanceof MethodMixIn) {
                // If implements MethodMixIn, do not attempt to locate opaque method, as this is expensive
                MethodMixIn methodMixIn = (MethodMixIn) bean;
                return methodMixIn.getAdditionalMethods().tryInvokeMethod(name, arguments);
            }

            if (!implementsMissing) {
                return DynamicInvokeResult.notFound();
            }

            return invokeOpaqueMethod(metaClass, name, arguments);
        }

        @Nullable
        protected MetaMethod lookupMethod(MetaClass metaClass, String name, Class[] arguments) {
            return metaClass.pickMethod(name, arguments);
        }

        protected DynamicInvokeResult invokeOpaqueMethod(MetaClass metaClass, String name, Object[] arguments) {
            MetaMethod methodMissingMethod = findMethodMissingMethod(metaClass);
            if (methodMissingMethod != null) {
                try {
                    try {
                        return DynamicInvokeResult.found(methodMissingMethod.invoke(bean, new Object[] {name, arguments}));
                    } catch (InvokerInvocationException e) {
                        if (e.getCause() instanceof MissingMethodException) {
                            throw (MissingMethodException) e.getCause();
                        }
                        throw e;
                    }
                } catch (MissingMethodException e) {
                    if (!e.getMethod().equals(name) || !Arrays.equals(e.getArguments(), arguments)) {
                        throw e;
                    }
                    // Ignore
                }
            }
            return DynamicInvokeResult.notFound();
        }
    }

    /*
       The GroovyObject interface defines dynamic property and dynamic method methods. Implementers
       are free to implement their own logic in these methods which makes it invisible to the metaclass.

       The most notable case of this is Closure.

       So in this case we use these methods directly on the GroovyObject in case it does implement logic at this level.
     */
    private class GroovyObjectAdapter extends MetaClassAdapter {
        private final GroovyObject groovyObject = (GroovyObject) bean;

        @Override
        protected DynamicInvokeResult getOpaqueProperty(String name) {
            try {
                return DynamicInvokeResult.found(groovyObject.getProperty(name));
            } catch (MissingPropertyException e) {
                if (!name.equals(e.getProperty())) {
                    throw e;
                }
                // Else, ignore
            }
            return DynamicInvokeResult.notFound();
        }

        @Override
        protected DynamicInvokeResult setOpaqueProperty(MetaClass metaClass, String name, Object value) {
            try {
                groovyObject.setProperty(name, value);
                return DynamicInvokeResult.found();
            } catch (MissingPropertyException e) {
                if (!name.equals(e.getProperty())) {
                    throw e;
                }
                // Else, ignore
            }
            return DynamicInvokeResult.notFound();
        }

        @Override
        protected DynamicInvokeResult invokeOpaqueMethod(MetaClass metaClass, String name, Object[] arguments) {
            try {
                try {
                    return DynamicInvokeResult.found(groovyObject.invokeMethod(name, arguments));
                } catch (InvokerInvocationException e) {
                    if (e.getCause() instanceof RuntimeException) {
                        throw (RuntimeException) e.getCause();
                    }
                    throw e;
                }
            } catch (MissingMethodException e) {
                if (!e.getMethod().equals(name) || !Arrays.equals(e.getArguments(), arguments)) {
                    throw e;
                }
                // Else, ignore
            }
            return DynamicInvokeResult.notFound();
        }
    }

    private class MapAdapter extends MetaClassAdapter {
        Map<String, Object> map = (Map<String, Object>) bean;

        @Override
        public boolean hasProperty(String name) {
            return map.containsKey(name) || super.hasProperty(name);
        }

        @Override
        protected DynamicInvokeResult getOpaqueProperty(String name) {
            return DynamicInvokeResult.found(map.get(name));
        }

        @Override
        protected void getOpaqueProperties(Map<String, Object> properties) {
            properties.putAll(map);
        }

        @Override
        protected DynamicInvokeResult setOpaqueProperty(MetaClass metaClass, String name, Object value) {
            map.put(name, value);
            return DynamicInvokeResult.found();
        }
    }

    private class ClassAdapter extends MetaClassAdapter {
        private final MetaClass classMetaData;

        ClassAdapter(Class<?> cl) {
            classMetaData = GroovySystem.getMetaClassRegistry().getMetaClass(cl);
        }

        @Nullable
        @Override
        protected MetaProperty lookupProperty(MetaClass metaClass, String name) {
            MetaProperty metaProperty = super.lookupProperty(metaClass, name);
            if (metaProperty != null) {
                return metaProperty;
            }
            metaProperty = classMetaData.getMetaProperty(name);
            if (metaProperty != null && Modifier.isStatic(metaProperty.getModifiers())) {
                return metaProperty;
            }
            return null;
        }

        @Nullable
        @Override
        protected MetaMethod lookupMethod(MetaClass metaClass, String name, Class[] arguments) {
            MetaMethod metaMethod = super.lookupMethod(metaClass, name, arguments);
            if (metaMethod != null) {
                return metaMethod;
            }
            metaMethod = classMetaData.getMetaMethod(name, arguments);
            if (metaMethod != null && Modifier.isStatic(metaMethod.getModifiers())) {
                return metaMethod;
            }
            return null;
        }
    }
}
