/******************************************************************************
 * Copyright (c) 2016-2018 TypeFox and others.
 * 
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License v. 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0,
 * or the Eclipse Distribution License v. 1.0 which is available at
 * http://www.eclipse.org/org/documents/edl-v10.php.
 * 
 * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause
 ******************************************************************************/
package org.eclipse.lsp4j.jsonrpc.json.adapters;

import java.io.IOException;
import java.lang.reflect.Type;
import java.util.Collection;
import java.util.HashSet;
import java.util.function.Predicate;

import org.eclipse.lsp4j.jsonrpc.messages.Either;
import org.eclipse.lsp4j.jsonrpc.messages.Either3;
import org.eclipse.lsp4j.jsonrpc.messages.Tuple;

import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;
import com.google.gson.TypeAdapter;
import com.google.gson.TypeAdapterFactory;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter;

/**
 * Type adapter for {@link Either} and {@link Either3}.
 */
public class EitherTypeAdapter<L, R> extends TypeAdapter<Either<L, R>> {

	public static class Factory implements TypeAdapterFactory {

		@SuppressWarnings({ "rawtypes", "unchecked" })
		@Override
		public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
			if (!TypeUtils.isEither(typeToken.getType())) {
				return null;
			}
			return new EitherTypeAdapter(gson, typeToken);
		}

	}
	
	/**
	 * A predicate that is usedful for checking alternatives in case both the left and the right type
	 * are JSON object types.
	 */
	public static class PropertyChecker implements Predicate<JsonElement> {
		
		private final String propertyName;
		private final String expectedValue;
		private final Class<? extends JsonElement> expectedType;
		
		public PropertyChecker(String propertyName) {
			this.propertyName = propertyName;
			this.expectedValue = null;
			this.expectedType = null;
		}
		
		public PropertyChecker(String propertyName, String expectedValue) {
			this.propertyName = propertyName;
			this.expectedValue = expectedValue;
			this.expectedType = null;
		}
		
		public PropertyChecker(String propertyName, Class<? extends JsonElement> expectedType) {
			this.propertyName = propertyName;
			this.expectedType = expectedType;
			this.expectedValue = null;
		}

		@Override
		public boolean test(JsonElement element) {
			if (element.isJsonObject()) {
				JsonObject object = element.getAsJsonObject();
				JsonElement value = object.get(propertyName);
				if (expectedValue != null)
					return value != null && value.isJsonPrimitive() && expectedValue.equals(value.getAsString());
				else if (expectedType != null)
					return expectedType.isInstance(value);
				else
					return value != null;
			}
			return false;
		}
		
	}
	
	/**
	 * A predicate for the case that a type alternative is a list.
	 */
	public static class ListChecker implements Predicate<JsonElement> {
		
		private final Predicate<JsonElement> elementChecker;
		private final boolean resultIfEmpty;
		
		public ListChecker(Predicate<JsonElement> elementChecker) {
			this(elementChecker, false);
		}
		
		public ListChecker(Predicate<JsonElement> elementChecker, boolean resultIfEmpty) {
			this.elementChecker = elementChecker;
			this.resultIfEmpty = resultIfEmpty;
		}

		@Override
		public boolean test(JsonElement t) {
			if (elementChecker.test(t))
				return true;
			if (t.isJsonArray()) {
				JsonArray array = t.getAsJsonArray();
				if (array.size() == 0)
					return resultIfEmpty;
				for (JsonElement e : array) {
					if (elementChecker.test(e))
						return true;
				}
			}
			return false;
		}
		
	}

	protected final TypeToken<Either<L, R>> typeToken;
	protected final EitherTypeArgument<L> left;
	protected final EitherTypeArgument<R> right;
	protected final Predicate<JsonElement> leftChecker;
	protected final Predicate<JsonElement> rightChecker;

	public EitherTypeAdapter(Gson gson, TypeToken<Either<L, R>> typeToken) {
		this(gson, typeToken, null, null);
	}
	
	public EitherTypeAdapter(Gson gson, TypeToken<Either<L, R>> typeToken, Predicate<JsonElement> leftChecker, Predicate<JsonElement> rightChecker) {
		this.typeToken = typeToken;
		Type[] elementTypes = TypeUtils.getElementTypes(typeToken, Either.class);
		this.left = new EitherTypeArgument<L>(gson, elementTypes[0]);
		this.right = new EitherTypeArgument<R>(gson, elementTypes[1]);
		this.leftChecker = leftChecker;
		this.rightChecker = rightChecker;
	}

	@Override
	public void write(JsonWriter out, Either<L, R> value) throws IOException {
		if (value == null) {
			out.nullValue();
		} else if (value.isLeft()) {
			left.write(out, value.getLeft());
		} else {
			right.write(out, value.getRight());
		}
	}

	@Override
	public Either<L, R> read(JsonReader in) throws IOException {
		JsonToken next = in.peek();
		if (next == JsonToken.NULL) {
			in.nextNull();
			return null;
		}
		return create(next, in);
	}

	protected Either<L, R> create(JsonToken nextToken, JsonReader in) throws IOException {
		boolean matchesLeft = left.isAssignable(nextToken);
		boolean matchesRight = right.isAssignable(nextToken);
		if (matchesLeft && matchesRight) {
			if (leftChecker != null || rightChecker != null) {
				JsonElement element = new JsonParser().parse(in);
				if (leftChecker != null && leftChecker.test(element))
					// Parse the left alternative from the JSON element tree
					return createLeft(left.read(element));
				if (rightChecker != null && rightChecker.test(element))
					// Parse the right alternative from the JSON element tree
					return createRight(right.read(element));
			}
			throw new JsonParseException("Ambiguous Either type: token " + nextToken + " matches both alternatives.");
		} else if (matchesLeft) {
			// Parse the left alternative from the JSON stream
			return createLeft(left.read(in));
		} else if (matchesRight) {
			// Parse the right alternative from the JSON stream
			return createRight(right.read(in));
		} else {
			throw new JsonParseException("Unexpected token " + nextToken + ": expected " + left + " | " + right + " tokens.");
		}
	}
	
	@SuppressWarnings("unchecked")
	protected Either<L, R> createLeft(L obj) throws IOException {
		if (Either3.class.isAssignableFrom(typeToken.getRawType()))
			return (Either<L, R>) Either3.forLeft3(obj);
		else
			return Either.forLeft(obj);
	}
	
	@SuppressWarnings("unchecked")
	protected Either<L, R> createRight(R obj) throws IOException {
		if (Either3.class.isAssignableFrom(typeToken.getRawType()))
			return (Either<L, R>) Either3.forRight3((Either<?, ?>) obj);
		else
			return Either.forRight(obj);
	}

	protected static class EitherTypeArgument<T> {

		protected final TypeToken<T> typeToken;
		protected final TypeAdapter<T> adapter;
		protected final Collection<JsonToken> expectedTokens;

		@SuppressWarnings("unchecked")
		public EitherTypeArgument(Gson gson, Type type) {
			this.typeToken = (TypeToken<T>) TypeToken.get(type);
			this.adapter = gson.getAdapter(this.typeToken);
			this.expectedTokens = new HashSet<>();
			for (Type expectedType : TypeUtils.getExpectedTypes(type)) {
				Class<?> rawType = TypeToken.get(expectedType).getRawType();
				JsonToken expectedToken = getExpectedToken(rawType);
				expectedTokens.add(expectedToken);
			}
		}

		protected JsonToken getExpectedToken(Class<?> rawType) {
			if (rawType.isArray() || Collection.class.isAssignableFrom(rawType) || Tuple.class.isAssignableFrom(rawType)) {
				return JsonToken.BEGIN_ARRAY;
			}
			if (Boolean.class.isAssignableFrom(rawType)) {
				return JsonToken.BOOLEAN;
			}
			if (Number.class.isAssignableFrom(rawType) || Enum.class.isAssignableFrom(rawType)) {
				return JsonToken.NUMBER;
			}
			if (Character.class.isAssignableFrom(rawType) || String.class.isAssignableFrom(rawType)) {
				return JsonToken.STRING;
			}
			return JsonToken.BEGIN_OBJECT;
		}

		public boolean isAssignable(JsonToken jsonToken) {
			return this.expectedTokens.contains(jsonToken);
		}

		public void write(JsonWriter out, T value) throws IOException {
			this.adapter.write(out, value);
		}

		public T read(JsonReader in) throws IOException {
			return this.adapter.read(in);
		}
		
		public T read(JsonElement element) throws IOException {
			return this.adapter.fromJsonTree(element);
		}

		@Override
		public String toString() {
			StringBuilder builder = new StringBuilder();
			for (JsonToken expectedToken : expectedTokens) {
				if (builder.length() != 0) {
					builder.append(" | ");
				}
				builder.append(expectedToken);
			}
			return builder.toString();
		}

	}

}