/*
 *  Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you 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.codehaus.groovy.runtime.metaclass;

import groovy.lang.Closure;
import groovy.lang.ExpandoMetaClass;
import groovy.lang.GroovyInterceptable;
import groovy.lang.GroovyObject;
import groovy.lang.GroovyRuntimeException;
import groovy.lang.MetaBeanProperty;
import groovy.lang.MetaClass;
import groovy.lang.MetaClassImpl;
import groovy.lang.MetaClassRegistry;
import groovy.lang.MetaMethod;
import groovy.lang.MetaProperty;
import groovy.lang.MissingMethodException;
import groovy.lang.ProxyMetaClass;
import org.codehaus.groovy.reflection.CachedClass;
import org.codehaus.groovy.reflection.CachedField;
import org.codehaus.groovy.reflection.CachedMethod;
import org.codehaus.groovy.reflection.ParameterTypes;
import org.codehaus.groovy.runtime.GeneratedClosure;
import org.codehaus.groovy.runtime.InvokerHelper;
import org.codehaus.groovy.runtime.MetaClassHelper;
import org.codehaus.groovy.runtime.callsite.CallSite;
import org.codehaus.groovy.runtime.callsite.PogoMetaClassSite;
import org.codehaus.groovy.runtime.wrappers.Wrapper;
import org.codehaus.groovy.util.FastArray;

import java.beans.PropertyDescriptor;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * A metaclass for closures generated by the Groovy compiler. These classes
 * have special characteristics this MetaClass uses. One of these is that a
 * generated Closure has only additional doCall methods, all other methods
 * are in the Closure class as well. To use this fact this MetaClass uses
 * a MetaClass for Closure as static field And delegates calls to this
 * MetaClass if needed. This allows a lean implementation for this MetaClass.
 * Multiple generated closures will then use the same MetaClass for Closure.
 * For static dispatching this class uses the MetaClass of Class, again
 * all instances of this class will share that MetaClass. The Class MetaClass
 * is initialized lazy, because most operations do not need this MetaClass.
 * <p>
 * The Closure and Class MetaClasses are not replaceable.
 * <p>
 * This MetaClass is for internal usage only!
 *
 * @since 1.5
 */
public final class ClosureMetaClass extends MetaClassImpl {
    private volatile boolean initialized, attributeInitDone;
    private final FastArray closureMethods = new FastArray(3);
    private final Map<String, CachedField> attributes = new HashMap<>();
    private MethodChooser chooser;

    private static MetaClassImpl classMetaClass;
    private static MetaClassImpl CLOSURE_METACLASS;
    private static final Object[] EMPTY_ARGUMENTS = {};
    private static final String CLOSURE_CALL_METHOD = "call";
    private static final String CLOSURE_DO_CALL_METHOD = "doCall";

    static {
        resetCachedMetaClasses();
    }

    public static void resetCachedMetaClasses() {
        MetaClassImpl temp = new MetaClassImpl(Closure.class);
        temp.initialize();
        synchronized (ClosureMetaClass.class) {
            CLOSURE_METACLASS = temp;
        }
        if (classMetaClass != null) {
            temp = new MetaClassImpl(Class.class);
            temp.initialize();
            synchronized (ClosureMetaClass.class) {
                classMetaClass = temp;
            }
        }
    }

    private static synchronized MetaClass getStaticMetaClass() {
        if (classMetaClass == null) {
            classMetaClass = new MetaClassImpl(Class.class);
            classMetaClass.initialize();
        }
        return classMetaClass;
    }

    private interface MethodChooser {
        Object chooseMethod(Class[] arguments, boolean coerce);
    }

    private static class StandardClosureChooser implements MethodChooser {
        private final MetaMethod doCall0;
        private final MetaMethod doCall1;

        StandardClosureChooser(final MetaMethod m0, final MetaMethod m1) {
            doCall0 = m0;
            doCall1 = m1;
        }

        @Override
        public Object chooseMethod(final Class[] arguments, final boolean coerce) {
            if (arguments.length == 0) return doCall0;
            if (arguments.length == 1) return doCall1;
            return null;
        }
    }

    private static class NormalMethodChooser implements MethodChooser {
        private final FastArray methods;
        final Class theClass;

        NormalMethodChooser(final Class theClass, final FastArray methods) {
            this.theClass = theClass;
            this.methods = methods;
        }

        @Override
        public Object chooseMethod(final Class[] arguments, final boolean coerce) {
            if (arguments.length == 0) {
                return MetaClassHelper.chooseEmptyMethodParams(methods);
            } else if (arguments.length == 1 && arguments[0] == null) {
                return MetaClassHelper.chooseMostGeneralMethodWith1NullParam(methods);
            } else {
                final Object[] data = methods.getArray();
                final int methodCount = methods.size();
                List matchingMethods = new ArrayList(methodCount);
                for (int i = 0; i < methodCount; i += 1) {
                    Object method = data[i];

                    // making this false helps find matches
                    if (((ParameterTypes) method).isValidMethod(arguments)) {
                        matchingMethods.add(method);
                    }
                }

                int size = matchingMethods.size();
                if (0 == size) {
                    return null;
                } else if (1 == size) {
                    return matchingMethods.get(0);
                }

                return chooseMostSpecificParams(CLOSURE_DO_CALL_METHOD, matchingMethods, arguments);
            }
        }

        private Object chooseMostSpecificParams(final String name, final List matchingMethods, final Class[] arguments) {
            return doChooseMostSpecificParams(theClass.getName(), name, matchingMethods, arguments, true);
        }
    }

    //--------------------------------------------------------------------------

    public ClosureMetaClass(final MetaClassRegistry registry, final Class theClass) {
        super(registry, theClass);
    }

    @Override
    public MetaProperty getMetaProperty(final String name) {
        return CLOSURE_METACLASS.getMetaProperty(name);
    }

    private MetaMethod pickClosureMethod(final Class[] argClasses) {
        return (MetaMethod) chooser.chooseMethod(argClasses, false);
    }

    private MetaMethod getDelegateMethod(final Closure closure, final Object delegate, final String methodName, final Class[] argClasses) {
        if (delegate == closure || delegate == null) {
            return null;
        }

        if (delegate instanceof Class) {
            for (var type = (Class<?>) delegate; type != Object.class && type != null; type = type.getSuperclass()) {
                MetaMethod method = registry.getMetaClass(type).getStaticMetaMethod(methodName, argClasses);
                if (method != null) {
                    return method;
                }
            }
            return null;
        }

        MetaClass delegateMetaClass = lookupObjectMetaClass(delegate);

        if (delegate instanceof GroovyInterceptable) {
            // GROOVY-3015: must route calls through GroovyObject#invokeMethod(String,Object)
            MetaMethod interceptMethod = delegateMetaClass.pickMethod("invokeMethod", new Class[]{String.class, Object.class});
            return new TransformMetaMethod(interceptMethod) {
                @Override
                public Object invoke(final Object object, final Object[] arguments) {
                    return super.invoke(object, new Object[]{methodName, arguments});
                }
            };
        }

        MetaMethod method = delegateMetaClass.pickMethod(methodName, argClasses);
        if (method == null) {
            if (delegateMetaClass instanceof ExpandoMetaClass) {
                method = ((ExpandoMetaClass) delegateMetaClass).findMixinMethod(methodName, argClasses);
                if (method != null) {
                    onMixinMethodFound(method);
                }
            } else if (delegateMetaClass instanceof MetaClassImpl) {
                method = MetaClassImpl.findMethodInClassHierarchy(getTheClass(), methodName, argClasses, this);
                if (method != null) {
                    onSuperMethodFoundInHierarchy(method);
                }
            }
        }
        return method;
    }

    @Override
    public Object invokeMethod(final Class sender, final Object object, final String methodName, final Object[] arguments, final boolean isCallToSuper, final boolean fromInsideClass) {
        checkInitalised();
        if (object == null) {
            throw new NullPointerException("Cannot invoke method: " + methodName + " on null object");
        }

        final Object[] theArguments = arguments == null ? EMPTY_ARGUMENTS : arguments.clone();
        final Class<?>[] argClasses = MetaClassHelper.convertToTypeArray(theArguments);

        MetaMethod method = null;
        final var closure = (Closure<?>) object;
        final int resolveStrategy = closure.getResolveStrategy();

        if (CLOSURE_DO_CALL_METHOD.equals(methodName) || CLOSURE_CALL_METHOD.equals(methodName)) {
            method = pickClosureMethod(argClasses);
            if (method == null && argClasses.length == 1 && List.class.isAssignableFrom(argClasses[0])) {
                var list = (theArguments[0] instanceof Wrapper ? ((Wrapper) theArguments[0]).unwrap() : theArguments[0]);
                if (list != null) {
                    var newArguments = ((List<?>) list).toArray();
                    var newArgClasses = MetaClassHelper.convertToTypeArray(newArguments);
                    method = createTransformMetaMethod(pickClosureMethod(newArgClasses));
                }
            }
            if (method == null) throw new MissingMethodException(methodName, theClass, theArguments, false);
        }

        if (method == null && (resolveStrategy != Closure.DELEGATE_ONLY || !isInternalMethod(methodName))) {
            method = CLOSURE_METACLASS.pickMethod(methodName, argClasses);
        }

        if (method != null) return method.doMethodInvoke(closure, theArguments);

        Object callObject = closure; // target for method
        final Object delegate = closure.getDelegate(), owner = closure.getOwner();
        boolean invokeOnDelegate = false, invokeOnOwner = false, ownerFirst = true;

        switch (resolveStrategy) {
          case Closure.TO_SELF:
            break;
          case Closure.DELEGATE_ONLY:
            method = getDelegateMethod(closure, delegate, methodName, argClasses);
            callObject = delegate;
            if (method == null) {
                invokeOnDelegate = (delegate != closure) && (delegate instanceof GroovyObject);
            }
            break;
          case Closure.OWNER_ONLY:
            method = getDelegateMethod(closure, owner, methodName, argClasses);
            callObject = owner;
            if (method == null) {
                invokeOnOwner = (owner != closure) && (owner instanceof GroovyObject);
            }
            break;
          case Closure.DELEGATE_FIRST:
            method = getDelegateMethod(closure, delegate, methodName, argClasses);
            callObject = delegate;
            if (method == null) {
                invokeOnDelegate = (delegate != closure);
                invokeOnOwner = (owner != closure);
                ownerFirst = false;
            }
            break;
          default: //Closure.OWNER_FIRST:
            method = getDelegateMethod(closure, owner, methodName, argClasses);
            callObject = owner;
            if (method == null) {
                invokeOnDelegate = (delegate != closure);
                invokeOnOwner = (owner != closure);
                ownerFirst = true;
            }
            break;
        }

        if (method != null) {
            var metaClass = registry.getMetaClass(callObject.getClass());
            if (metaClass instanceof ProxyMetaClass) {
                return metaClass.invokeMethod(callObject, methodName, arguments);
            } else {
                return method.doMethodInvoke(callObject, theArguments); // direct
            }
        } else if (invokeOnOwner || invokeOnDelegate) {
            if (ownerFirst) {
                return invokeOnDelegationObjects(invokeOnOwner, owner, invokeOnDelegate, delegate, methodName, arguments, sender);
            } else {
                return invokeOnDelegationObjects(invokeOnDelegate, delegate, invokeOnOwner, owner, methodName, arguments, sender);
            }
        }

        throw new MissingMethodException(methodName, theClass, theArguments, false);
    }

    private static Object invokeOnDelegationObjects(final boolean i1, final Object o1, final boolean i2, final Object o2, final String methodName, final Object[] args, final Class c) {
        MissingMethodException first = null;
        if (i1) {
            try {
                return invokeOnDelegationObject(c, o1, methodName, args);
            } catch (MissingMethodException mme) {
                first = mme;
            }
        }
        if (i2 && (!i1 || o1 != o2)) {
            try {
                return invokeOnDelegationObject(c, o2, methodName, args);
            } catch (MissingMethodException mme) {
                if (first == null) first = mme;
                else first.addSuppressed(mme);
            }
        }
        throw first;
    }

    private static Object invokeOnDelegationObject(final Class sender, final Object object, final String methodName, final Object[] arguments) {
        MissingMethodException first = null;
        try {
            return InvokerHelper.invokeMethod(object, methodName, arguments); // includes callable property
        } catch (MissingMethodException mme) {
            first = mme;
        } catch (GroovyRuntimeException gre) {
            Throwable t = gre;
            while (t.getCause() != t && t.getCause() instanceof GroovyRuntimeException) t = t.getCause();
            if (t instanceof MissingMethodException && methodName.equals(((MissingMethodException) t).getMethod())) {
                first = (MissingMethodException) t;
            } else {
                throw gre;
            }
        }
        Class thisType = sender;
        while (GeneratedClosure.class.isAssignableFrom(thisType)) thisType = thisType.getEnclosingClass();
        if (thisType != sender && thisType != object.getClass() && thisType.isInstance(object)) { // GROOVY-2433, GROOVY-11128
            try {
                return ((GroovyObject) object).getMetaClass().invokeMethod(thisType, object, methodName, arguments, false, true);
            } catch (GroovyRuntimeException gre) {
                first.addSuppressed(gre);
            }
        }
        throw first;
    }

    private static final Set<String> INTERNAL_METHODS = new HashSet<>(Arrays.asList("curry", "ncurry", "rcurry", "leftShift", "rightShift"));
    private static boolean isInternalMethod(final String methodName) {
        return INTERNAL_METHODS.contains(methodName);
    }

    private synchronized void initAttributes() {
        if (attributes.isEmpty()) {
            attributes.put("!", null); // a dummy for later
            for (var field : theCachedClass.getFields()) {
                attributes.put(field.getName(), field);
            }
            attributeInitDone = !attributes.isEmpty();
        }
    }

    @Override
    public synchronized void initialize() {
        if (!isInitialized()) {
            CachedMethod[] methodArray = theCachedClass.getMethods();
            for (final CachedMethod cachedMethod : methodArray) {
                if (!cachedMethod.getName().equals(CLOSURE_DO_CALL_METHOD)) continue;
                closureMethods.add(cachedMethod);
            }
            assignMethodChooser();
            super.reinitialize();
        }
    }

    private void assignMethodChooser() {
        if (closureMethods.size() == 1) {
            final MetaMethod doCall = (MetaMethod) closureMethods.get(0);
            final CachedClass[] c = doCall.getParameterTypes();
            int length = c.length;
            if (length == 0) {
                // no arg method
                chooser = (arguments, coerce) -> {
                    if (arguments.length == 0) return doCall;
                    return null;
                };
            } else {
                if (length == 1 && c[0].getTheClass() == Object.class) {
                    // Object fits all, so simple dispatch rule here
                    chooser = (arguments, coerce) -> {
                        // <2, because foo() is same as foo(null)
                        if (arguments.length < 2) return doCall;
                        return null;
                    };
                } else {
                    boolean allObject = true;
                    for (int i = 0; i < c.length - 1; i++) {
                        if (c[i].getTheClass() != Object.class) {
                            allObject = false;
                            break;
                        }
                    }
                    if (allObject && c[c.length - 1].getTheClass() == Object.class) {
                        // all arguments are object, so test only if argument number is correct
                        chooser = (arguments, coerce) -> {
                            if (arguments.length == c.length) return doCall;
                            return null;
                        };
                    } else {
                        if (allObject && c[c.length - 1].getTheClass() == Object[].class) {
                            // all arguments are Object but last, which is a vargs argument, that
                            // will fit all, so just test if the number of argument is equal or
                            // more than the parameters we have.
                            final int minimumLength = c.length - 2;
                            chooser = (arguments, coerce) -> {
                                if (arguments.length > minimumLength) return doCall;
                                return null;
                            };
                        } else {
                            // general case for single method
                            chooser = (arguments, coerce) -> {
                                if (doCall.isValidMethod(arguments)) {
                                    return doCall;
                                }
                                return null;
                            };
                        }
                    }
                }
            }
        } else if (closureMethods.size() == 2) {
            MetaMethod m0 = null, m1 = null;
            for (int i = 0; i != closureMethods.size(); ++i) {
                MetaMethod m = (MetaMethod) closureMethods.get(i);
                CachedClass[] c = m.getParameterTypes();
                if (c.length == 0) {
                    m0 = m;
                } else {
                    if (c.length == 1 && c[0].getTheClass() == Object.class) {
                        m1 = m;
                    }
                }
            }
            if (m0 != null && m1 != null) {
                // standard closure (2 methods because "it" is with default null)
                chooser = new StandardClosureChooser(m0, m1);
            }
        }
        if (chooser == null) {
            // standard chooser for cases if it is not a single method and if it is
            // not the standard closure.
            chooser = new NormalMethodChooser(theClass, closureMethods);
        }
    }

    private MetaClass lookupObjectMetaClass(final Object object) {
        MetaClass metaClass;
        if (object instanceof GroovyObject) {
            metaClass = ((GroovyObject) object).getMetaClass();
        } else if (object.getClass() == Class.class) {
            metaClass = registry.getMetaClass((Class)object);
        } else {
            metaClass = InvokerHelper.getMetaClass(object);
        }
        return metaClass;
    }

    @Override
    public List<MetaMethod> getMethods() {
        List<MetaMethod> answer = CLOSURE_METACLASS.getMetaMethods();
        answer.addAll(closureMethods.toList());
        return answer;
    }

    @Override
    public List<MetaMethod> getMetaMethods() {
        return CLOSURE_METACLASS.getMetaMethods();
    }

    @Override
    public List<MetaProperty> getProperties() {
        return CLOSURE_METACLASS.getProperties();
    }

    @Override
    public MetaMethod pickMethod(final String methodName, final Class[] argumentTypes) {
        if (CLOSURE_CALL_METHOD.equals(methodName) || CLOSURE_DO_CALL_METHOD.equals(methodName)) {
            return pickClosureMethod(argumentTypes != null ? argumentTypes : MetaClassHelper.EMPTY_CLASS_ARRAY);
        }
        return CLOSURE_METACLASS.getMetaMethod(methodName, argumentTypes);
    }

    public MetaMethod retrieveStaticMethod(final String methodName, final Class[] arguments) {
        return null;
    }

    @Override
    protected boolean isInitialized() {
        return initialized;
    }

    @Override
    protected void setInitialized(final boolean initialized) {
        this.initialized = initialized;
    }

    @Override
    public MetaMethod getStaticMetaMethod(final String name, final Object[] args) {
        return CLOSURE_METACLASS.getStaticMetaMethod(name, args);
    }

    public MetaMethod getStaticMetaMethod(final String name, final Class[] argTypes) {
        return CLOSURE_METACLASS.getStaticMetaMethod(name, argTypes);
    }

    @Override
    public Object getProperty(final Class sender, final Object object, final String name, final boolean useSuper, final boolean fromInsideClass) {
        if (object instanceof Class) {
            return getStaticMetaClass().getProperty(sender, object, name, useSuper, fromInsideClass);
        } else {
            return CLOSURE_METACLASS.getProperty(sender, object, name, useSuper, fromInsideClass);
        }
    }

    @Override
    public Object getAttribute(final Class sender, final Object object, final String attribute, final boolean useSuper, final boolean fromInsideClass) {
        if (object instanceof Class) {
            return getStaticMetaClass().getAttribute(sender, object, attribute, useSuper);
        } else {
            if (!attributeInitDone) initAttributes();
            CachedField mfp = attributes.get(attribute);
            if (mfp == null) {
                return CLOSURE_METACLASS.getAttribute(sender, object, attribute, useSuper);
            } else {
                return mfp.getProperty(object);
            }
        }
    }

    @Override
    public void setAttribute(final Class sender, final Object object, final String attribute, final Object newValue, final boolean useSuper, final boolean fromInsideClass) {
        if (object instanceof Class) {
            getStaticMetaClass().setAttribute(sender, object, attribute, newValue, useSuper, fromInsideClass);
        } else {
            if (!attributeInitDone) initAttributes();
            CachedField mfp = attributes.get(attribute);
            if (mfp == null) {
                CLOSURE_METACLASS.setAttribute(sender, object, attribute, newValue, useSuper, fromInsideClass);
            } else {
                mfp.setProperty(object, newValue);
            }
        }
    }

    @Override
    public Object invokeStaticMethod(final Object object, final String methodName, final Object[] arguments) {
        return getStaticMetaClass().invokeMethod(Class.class, object, methodName, arguments, false, false);
    }

    @Override
    public void setProperty(final Class sender, final Object object, final String name, final Object newValue, final boolean useSuper, final boolean fromInsideClass) {
        if (object instanceof Class) {
            getStaticMetaClass().setProperty(sender, object, name, newValue, useSuper, fromInsideClass);
        } else {
            CLOSURE_METACLASS.setProperty(sender, object, name, newValue, useSuper, fromInsideClass);
        }
    }

    public MetaMethod getMethodWithoutCaching(final int index, final Class sender, final String methodName, final Class[] arguments, final boolean isCallToSuper) {
        throw new UnsupportedOperationException();
    }

    @Override
    public void setProperties(final Object bean, final Map map) {
        throw new UnsupportedOperationException();
    }

    @Override
    public void addMetaBeanProperty(final MetaBeanProperty mp) {
        throw new UnsupportedOperationException();
    }

    @Override
    public void addMetaMethod(final MetaMethod method) {
        throw new UnsupportedOperationException();
    }

    @Override
    public void addNewInstanceMethod(final Method method) {
        throw new UnsupportedOperationException();
    }

    @Override
    public void addNewStaticMethod(final Method method) {
        throw new UnsupportedOperationException();
    }

    @Override
    public Constructor retrieveConstructor(final Class[] arguments) {
        throw new UnsupportedOperationException();
    }

    @Override
    public CallSite createPojoCallSite(final CallSite site, final Object receiver, final Object[] args) {
        throw new UnsupportedOperationException();
    }

    @Override
    public CallSite createPogoCallSite(final CallSite site, final Object[] args) {
        return new PogoMetaClassSite(site, this);
    }

    @Override
    public CallSite createPogoCallCurrentSite(final CallSite site, final Class sender, final Object[] args) {
        return new PogoMetaClassSite(site, this);
    }

    @Override
    public List respondsTo(final Object obj, final String name, final Object[] argTypes) {
        loadMetaInfo();
        return super.respondsTo(obj, name, argTypes);
    }

    @Override
    public List respondsTo(final Object obj, final String name) {
        loadMetaInfo();
        return super.respondsTo(obj, name);
    }

    private synchronized void loadMetaInfo() {
        if (!isInitialized()) {
          reinitialize();
        }
    }

    @Override
    protected void applyPropertyDescriptors(final PropertyDescriptor[] propertyDescriptors) {
        // do nothing
    }
}
