/*
 * Licensed to Elasticsearch under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch 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.elasticsearch.painless;

import java.lang.invoke.MethodType;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;

import static java.util.Collections.unmodifiableList;
import static org.elasticsearch.painless.WriterConstants.USES_PARAMETER_METHOD_TYPE;

/**
 * Information about the interface being implemented by the painless script.
 */
public class ScriptInterface {
    private final Class<?> iface;
    private final org.objectweb.asm.commons.Method executeMethod;
    private final Definition.Type executeMethodReturnType;
    private final List<MethodArgument> executeArguments;
    private final List<org.objectweb.asm.commons.Method> usesMethods;

    public ScriptInterface(Definition definition, Class<?> iface) {
        this.iface = iface;

        // Find the main method and the uses$argName methods
        java.lang.reflect.Method executeMethod = null;
        List<org.objectweb.asm.commons.Method> usesMethods = new ArrayList<>();
        for (java.lang.reflect.Method m : iface.getMethods()) {
            if (m.isDefault()) {
                continue;
            }
            if (m.getName().equals("execute")) {
                if (executeMethod == null) {
                    executeMethod = m;
                } else {
                    throw new IllegalArgumentException(
                            "Painless can only implement interfaces that have a single method named [execute] but [" + iface.getName()
                                    + "] has more than one.");
                }
                continue;
            }
            if (m.getName().startsWith("uses$")) {
                if (false == m.getReturnType().equals(boolean.class)) {
                    throw new IllegalArgumentException("Painless can only implement uses$ methods that return boolean but ["
                            + iface.getName() + "#" + m.getName() + "] returns [" + m.getReturnType().getName() + "].");
                }
                if (m.getParameterTypes().length > 0) {
                    throw new IllegalArgumentException("Painless can only implement uses$ methods that do not take parameters but ["
                            + iface.getName() + "#" + m.getName() + "] does.");
                }
                usesMethods.add(new org.objectweb.asm.commons.Method(m.getName(), USES_PARAMETER_METHOD_TYPE.toMethodDescriptorString()));
                continue;
            }
            throw new IllegalArgumentException("Painless can only implement methods named [execute] and [uses$argName] but ["
                    + iface.getName() + "] contains a method named [" + m.getName() + "]");
        }
        MethodType methodType = MethodType.methodType(executeMethod.getReturnType(), executeMethod.getParameterTypes());
        this.executeMethod = new org.objectweb.asm.commons.Method(executeMethod.getName(), methodType.toMethodDescriptorString());
        executeMethodReturnType = definitionTypeForClass(definition, executeMethod.getReturnType(),
                componentType -> "Painless can only implement execute methods returning a whitelisted type but [" + iface.getName()
                        + "#execute] returns [" + componentType.getName() + "] which isn't whitelisted.");

        // Look up the argument names
        Set<String> argumentNames = new LinkedHashSet<>();
        List<MethodArgument> arguments = new ArrayList<>();
        String[] argumentNamesConstant = readArgumentNamesConstant(iface);
        Class<?>[] types = executeMethod.getParameterTypes();
        if (argumentNamesConstant.length != types.length) {
            throw new IllegalArgumentException("[" + iface.getName() + "#ARGUMENTS] has length [2] but ["
                    + iface.getName() + "#execute] takes [1] argument.");
        }
        for (int arg = 0; arg < types.length; arg++) {
            arguments.add(methodArgument(definition, types[arg], argumentNamesConstant[arg]));
            argumentNames.add(argumentNamesConstant[arg]);
        }
        this.executeArguments = unmodifiableList(arguments);

        // Validate that the uses$argName methods reference argument names
        for (org.objectweb.asm.commons.Method usesMethod : usesMethods) {
            if (false == argumentNames.contains(usesMethod.getName().substring("uses$".length()))) {
                throw new IllegalArgumentException("Painless can only implement uses$ methods that match a parameter name but ["
                        + iface.getName() + "#" + usesMethod.getName() + "] doesn't match any of " + argumentNames + ".");
            }
        }
        this.usesMethods = unmodifiableList(usesMethods);
    }

    /**
     * The interface that the Painless script should implement.
     */
    public Class<?> getInterface() {
        return iface;
    }

    /**
     * An asm method descriptor for the {@code execute} method.
     */
    public org.objectweb.asm.commons.Method getExecuteMethod() {
        return executeMethod;
    }

    /**
     * The Painless {@link Definition.Type} or the return type of the {@code execute} method. This is used to generate the appropriate
     * return bytecode.
     */
    public Definition.Type getExecuteMethodReturnType() {
        return executeMethodReturnType;
    }

    /**
     * Painless {@link Definition.Type}s and names of the arguments to the {@code execute} method. The names are exposed to the Painless
     * script.
     */
    public List<MethodArgument> getExecuteArguments() {
        return executeArguments;
    }

    /**
     * The {@code uses$varName} methods that must be implemented by Painless to complete implementing the interface.
     */
    public List<org.objectweb.asm.commons.Method> getUsesMethods() {
        return usesMethods;
    }

    /**
     * Painless {@link Definition.Type}s and name of the argument to the {@code execute} method.
     */
    public static class MethodArgument {
        private final Definition.Type type;
        private final String name;

        public MethodArgument(Definition.Type type, String name) {
            this.type = type;
            this.name = name;
        }

        public Definition.Type getType() {
            return type;
        }

        public String getName() {
            return name;
        }
    }

    private MethodArgument methodArgument(Definition definition, Class<?> type, String argName) {
        Definition.Type defType = definitionTypeForClass(definition, type, componentType -> "[" + argName + "] is of unknown type ["
                + componentType.getName() + ". Painless interfaces can only accept arguments that are of whitelisted types.");
        return new MethodArgument(defType, argName);
    }

    private static Definition.Type definitionTypeForClass(Definition definition, Class<?> type,
            Function<Class<?>, String> unknownErrorMessageSource) {
        int dimensions = 0;
        Class<?> componentType = type;
        while (componentType.isArray()) {
            dimensions++;
            componentType = componentType.getComponentType();
        }
        Definition.Struct struct;
        if (componentType.equals(Object.class)) {
            struct = Definition.DEF_TYPE.struct;
        } else {
            Definition.RuntimeClass runtimeClass = definition.getRuntimeClass(componentType);
            if (runtimeClass == null) {
                throw new IllegalArgumentException(unknownErrorMessageSource.apply(componentType));
            }
            struct = runtimeClass.getStruct();
        }
        return definition.getType(struct, dimensions);
    }

    private static String[] readArgumentNamesConstant(Class<?> iface) {
        Field argumentNamesField;
        try {
            argumentNamesField = iface.getField("ARGUMENTS");
        } catch (NoSuchFieldException e) {
            throw new IllegalArgumentException("Painless needs a constant [String[] ARGUMENTS] on all interfaces it implements with the "
                    + "names of the method arguments but [" + iface.getName() + "] doesn't have one.", e);
        }
        if (false == argumentNamesField.getType().equals(String[].class)) {
            throw new IllegalArgumentException("Painless needs a constant [String[] ARGUMENTS] on all interfaces it implements with the "
                    + "names of the method arguments but [" + iface.getName() + "] doesn't have one.");
        }
        try {
            return (String[]) argumentNamesField.get(null);
        } catch (IllegalArgumentException | IllegalAccessException e) {
            throw new IllegalArgumentException("Error trying to read [" + iface.getName() + "#ARGUMENTS]", e);
        }
    }
}
