package openperipheral.adapter.method;

import com.google.common.base.Preconditions;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterators;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.reflect.TypeToken;
import java.lang.annotation.Annotation;
import java.lang.reflect.Array;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import openmods.reflection.TypeUtils;
import openmods.utils.AnnotationMap;
import openperipheral.adapter.AdapterLogicException;
import openperipheral.adapter.IMethodCall;
import openperipheral.adapter.IMethodDescription;
import openperipheral.adapter.types.TypeHelper;
import openperipheral.api.Constants;
import openperipheral.api.adapter.IScriptType;
import openperipheral.api.adapter.method.Alias;
import openperipheral.api.adapter.method.Arg;
import openperipheral.api.adapter.method.Env;
import openperipheral.api.adapter.method.IMultiReturn;
import openperipheral.api.adapter.method.MultipleReturn;
import openperipheral.api.adapter.method.Optionals;
import openperipheral.api.adapter.method.ReturnType;
import openperipheral.api.adapter.method.ScriptCallable;
import openperipheral.api.converter.IConverter;

public class MethodDeclaration implements IMethodDescription {

	public static class ArgumentDefinitionException extends IllegalStateException {
		private static final long serialVersionUID = -6428721405547878927L;

		public ArgumentDefinitionException(int argument, Throwable cause) {
			super(String.format("Failed to parse annotations on argument %d", argument), cause);
		}
	}

	private static class EnvArg {
		public final Class<?> cls;
		public final int index;

		public EnvArg(Class<?> cls, int index) {
			this.cls = cls;
			this.index = index;
		}
	}

	private final List<String> names;
	private final String source;
	private final Method method;
	private final String description;
	private final List<ReturnType> returnTypes;
	private final IScriptType wrappedReturn;

	private final boolean validateReturn;

	private final boolean multipleReturn;

	private final Map<Integer, Class<?>> unnamedEnvArg = Maps.newHashMap();

	private final Map<String, EnvArg> envArgs = Maps.newHashMap();

	private final List<Argument> callArgs = Lists.newArrayList();

	private final int argCount;

	private static List<String> getNames(Method method, ScriptCallable meta) {
		ImmutableList.Builder<String> names = ImmutableList.builder();

		String mainName = meta.name();

		if (ScriptCallable.USE_METHOD_NAME.equals(mainName)) names.add(method.getName());
		else names.add(mainName);

		Alias alias = method.getAnnotation(Alias.class);
		if (alias != null) names.add(alias.value());
		return names.build();
	}

	private static enum ArgParseState {
		ENV_UNNAMED,
		ENV_NAMED,
		ARG_REQUIRED,
		ARG_OPTIONAL,

	}

	public MethodDeclaration(Class<?> rootClass, Method method, ScriptCallable meta, String source) {
		this.method = method;
		this.source = source;

		this.names = getNames(method, meta);

		this.description = meta.description();
		this.returnTypes = ImmutableList.copyOf(meta.returnTypes());
		this.validateReturn = meta.validateReturn();

		this.multipleReturn = method.isAnnotationPresent(MultipleReturn.class);

		this.wrappedReturn = TypeHelper.createFromReturn(returnTypes);

		if (validateReturn) validateResultCount();

		final Type methodArgs[] = method.getGenericParameterTypes();
		final boolean isVarArg = method.isVarArgs();

		ArgParseState state = ArgParseState.ENV_UNNAMED;

		TypeToken<?> scopeType = TypeToken.of(rootClass);

		final Annotation[][] argsAnnotations = method.getParameterAnnotations();
		for (int argIndex = 0; argIndex < methodArgs.length; argIndex++) {
			try {
				final TypeToken<?> argType = scopeType.resolveType(methodArgs[argIndex]);

				AnnotationMap argAnnotations = new AnnotationMap(argsAnnotations[argIndex]);

				boolean optionalStart = argAnnotations.get(Optionals.class) != null;

				Env envArg = argAnnotations.get(Env.class);
				Arg luaArg = argAnnotations.get(Arg.class);

				Preconditions.checkState(envArg == null || luaArg == null, "@Arg and @Env are mutually exclusive");
				if (luaArg != null) {

					if (state != ArgParseState.ARG_OPTIONAL) state = ArgParseState.ARG_REQUIRED;

					if (optionalStart) {
						Preconditions.checkState(state != ArgParseState.ENV_NAMED, "@Optional used more than once");
						state = ArgParseState.ARG_OPTIONAL;
					}

					boolean isLastArg = argIndex == (methodArgs.length - 1);

					ArgumentBuilder builder = new ArgumentBuilder();
					builder.setVararg(isLastArg && isVarArg);
					builder.setOptional(state == ArgParseState.ARG_OPTIONAL);
					builder.setNullable(luaArg.isNullable());

					final Argument arg = builder.build(luaArg.name(), luaArg.description(), luaArg.type(), argType, argIndex);
					callArgs.add(arg);
				} else {
					Preconditions.checkState(state == ArgParseState.ENV_NAMED || state == ArgParseState.ENV_UNNAMED, "Unannotated arg in script part (perhaps missing @Arg annotation?)");
					Preconditions.checkState(!optionalStart, "@Optionals does not work for env arguments");

					Class<?> rawArgType = argType.getRawType();
					if (envArg != null) {
						Preconditions.checkState(state == ArgParseState.ENV_NAMED || state == ArgParseState.ENV_UNNAMED, "@Env annotation used in script part of arguments");
						final String envName = envArg.value();
						EnvArg prev = envArgs.put(envName, new EnvArg(rawArgType, argIndex));
						if (prev != null) { throw new IllegalStateException(String.format("Conflict on name %s, args: %s, %s", envArg, prev.index, argIndex)); }
						state = ArgParseState.ENV_NAMED;
					} else {
						Preconditions.checkState(state == ArgParseState.ENV_UNNAMED, "Unnamed env cannot occur after named");
						unnamedEnvArg.put(argIndex, rawArgType);
					}
				}
			} catch (Throwable t) {
				throw new ArgumentDefinitionException(argIndex, t);
			}
		}

		this.argCount = unnamedEnvArg.size() + envArgs.size() + callArgs.size();
		Preconditions.checkState(this.argCount == methodArgs.length, "Internal error for method %s", method);
	}

	private void validateResultCount() {
		Class<?> javaReturn = method.getReturnType();

		final int returnLength = returnTypes.size();

		for (ReturnType t : returnTypes) {
			Preconditions.checkArgument(t != ReturnType.VOID, "Method '%s' declares Void as return type. Use empty list instead.", method);
		}

		if (javaReturn == void.class) {
			Preconditions.checkArgument(returnLength == 0, "Method '%s' returns nothing, but declares at least one Lua result", method);
		}

		if (returnLength == 0) {
			Preconditions.checkArgument(javaReturn == void.class, "Method '%s' returns '%s', but declares no Lua results", method, javaReturn);
		}

		if (multipleReturn) {
			Preconditions.checkArgument(IMultiReturn.class.isAssignableFrom(javaReturn) || Collection.class.isAssignableFrom(javaReturn) || javaReturn.isArray(), "Method '%s' declared more than one Lua result, but returns single '%s' instead of array, collection or IMultiReturn", method, javaReturn);
		}

		if (returnLength > 1) {
			Preconditions.checkArgument(IMultiReturn.class.isAssignableFrom(javaReturn) || multipleReturn, "Method '%s' declared more than one Lua result, but returns single '%s' instead of array, collection or IMultiReturn", method, javaReturn);
		}
	}

	private static void checkReturnType(int argIndex, ReturnType expected, Object actual) {
		final Class<?> expectedJava = expected.getJavaType();
		Preconditions.checkArgument(actual == null || expectedJava.isInstance(actual) || TypeUtils.compareTypes(expectedJava, actual.getClass()), "Invalid type of return value %s: expected %s, got %s", argIndex, expected, actual);
	}

	private void validateResult(Object... result) {
		if (returnTypes.isEmpty()) {
			Preconditions.checkArgument(result.length == 1 && result[0] == null, "Returning value from null method");
		} else {
			Preconditions.checkArgument(result.length == returnTypes.size(), "Returning invalid number of values from method %s, expected %s, got %s", method, returnTypes.size(), result.length);
			for (int i = 0; i < result.length; i++)
				checkReturnType(i, returnTypes.get(i), result[i]);
		}
	}

	private static Object[] convertMultiResult(IConverter converter, IMultiReturn result) {
		return convertVarResult(converter, result.getObjects());
	}

	private static Object[] convertCollectionResult(IConverter converter, Collection<?> result) {
		Object[] tmp = new Object[result.size()];
		int i = 0;
		for (Object o : result)
			tmp[i++] = converter.fromJava(o);

		return tmp;
	}

	private static Object[] convertArrayResult(IConverter converter, Object array) {
		int length = Array.getLength(array);
		Object[] result = new Object[length];

		for (int i = 0; i < length; i++)
			result[i] = converter.fromJava(Array.get(array, i));

		return result;
	}

	private static Object[] convertVarResult(IConverter converter, Object... result) {
		for (int i = 0; i < result.length; i++)
			result[i] = converter.fromJava(result[i]);

		return result;
	}

	private Object[] convertResult(IConverter converter, Object result) {
		if (result instanceof IMultiReturn) return convertMultiResult(converter, (IMultiReturn)result);

		if (multipleReturn) {
			if (result != null && result.getClass().isArray()) return convertArrayResult(converter, result);
			if (result instanceof Collection) return convertCollectionResult(converter, (Collection<?>)result);
		}

		return convertVarResult(converter, result);
	}

	private class CallWrap implements IMethodCall {
		private final Object[] args = new Object[argCount];
		private final boolean[] isSet = new boolean[argCount];
		private final Object target;

		private IConverter converter;

		public CallWrap(Object target) {
			this.target = target;
		}

		private CallWrap setArg(int position, Object value) {
			boolean alreadyAdded = isSet[position];
			Preconditions.checkState(!alreadyAdded, "Trying to set already defined argument %s in method %s", position, method);

			args[position] = value;
			isSet[position] = true;
			return this;
		}

		@Override
		public IMethodCall setEnv(String name, Object value) {
			if (Constants.ARG_CONVERTER.equals(name)) this.converter = (IConverter)value;

			EnvArg arg = envArgs.get(name);
			if (arg != null) {
				Preconditions.checkState(value == null || arg.cls.isInstance(value),
						"Object of type %s cannot be used as argument %s (name: %s) in method %s",
						value != null? value.getClass() : "<null>", arg.index, name, method);
				setArg(arg.index, value);
			}
			return this;
		}

		private CallWrap setCallArgs(Object[] luaValues) {
			Preconditions.checkState(converter != null, "Converter not set!");
			try {
				Iterator<Object> it = Iterators.forArray(luaValues);
				try {
					for (Argument arg : callArgs) {
						Object value = arg.convert(converter, it);
						setArg(arg.javaArgIndex, value);
					}

					Preconditions.checkArgument(!it.hasNext(), "Too many arguments!");
				} catch (ArrayIndexOutOfBoundsException e) {
					throw new IllegalArgumentException(String.format("Invalid Lua parameter count, needs %s, got %s", callArgs.size(), luaValues.length));
				}
			} catch (IllegalArgumentException e) {
				throw e;
			} catch (Exception e) {
				throw new AdapterLogicException(e);
			}

			return this;
		}

		private Object[] call() throws Exception {
			Preconditions.checkState(converter != null, "Converter not set!");
			for (int i = 0; i < args.length; i++)
				Preconditions.checkState(isSet[i], "Parameter %s value not set", i);

			final Object result;
			try {
				result = method.invoke(target, args);
			} catch (InvocationTargetException e) {
				Throwable wrapper = e.getCause();
				throw Throwables.propagate(wrapper != null? wrapper : e);
			}

			final Object[] converted = convertResult(converter, result);
			if (validateReturn) validateResult(converted);
			return converted;
		}

		@Override
		public Object[] call(Object... args) throws Exception {
			setCallArgs(args);
			return call();
		}
	}

	public IMethodCall startCall(Object target) {
		return new CallWrap(target);
	}

	public void nameEnv(int index, String name, Class<?> expectedType) {
		Class<?> actualType = unnamedEnvArg.remove(index);
		Preconditions.checkState(actualType != null, "Argument at index %s not present or already named, can't name as %s");
		Preconditions.checkState(actualType.isAssignableFrom(expectedType), "Field %s (new name: %s) is expected to be %s, but has %s", index, name, expectedType, actualType);
		EnvArg prev = envArgs.put(name, new EnvArg(actualType, index));
		if (prev != null) throw new IllegalStateException(String.format("Name %s is already used: prev index: %d, new index: %d", name, prev.index, index));
	}

	public void verifyAllParamsNamed() {
		Preconditions.checkState(unnamedEnvArg.isEmpty(), "Env parameters not named: %s", unnamedEnvArg);
	}

	public void validateUnnamedEnvArgs(Class<?>... providedArgs) {
		Preconditions.checkState(providedArgs.length == unnamedEnvArg.size());
		for (int i = 0; i < providedArgs.length; i++) {
			final Class<?> needed = unnamedEnvArg.get(i);
			final Class<?> provided = providedArgs[i];
			Preconditions.checkState(needed.isAssignableFrom(provided),
					"Argument %s needs type %s, but %s provided", i, needed, provided);
		}
	}

	public void validateEnvArgs(Map<String, Class<?>> providedArgs) {
		for (Map.Entry<String, EnvArg> e : envArgs.entrySet()) {
			final EnvArg needed = e.getValue();
			final String name = e.getKey();
			final Class<?> provided = providedArgs.get(name);
			Preconditions.checkState(provided != null, "Method needs argument named %s (position %s) but it's not provided",
					name, needed.index);

			final Class<?> neededCls = needed.cls;
			Preconditions.checkState(neededCls.isAssignableFrom(provided),
					"Method needs argument named %s (position %s) of type %s, but %s was provided",
					name, needed.index, neededCls, provided);
		}
	}

	public Map<String, Class<?>> getOptionalArgs() {
		Map<String, Class<?>> result = Maps.newHashMap();

		for (Map.Entry<String, EnvArg> e : envArgs.entrySet())
			result.put(e.getKey(), e.getValue().cls);

		return result;
	}

	@Override
	public List<String> getNames() {
		return names;
	}

	@Override
	public String source() {
		return source;
	}

	@Override
	public List<IArgumentDescription> arguments() {
		List<? extends IArgumentDescription> cast = callArgs;
		return ImmutableList.copyOf(cast);
	}

	@Override
	public IScriptType returnTypes() {
		return wrappedReturn;
	}

	@Override
	public Set<String> attributes() {
		return Sets.newHashSet();
	}

	@Override
	public String description() {
		return description;
	}
}