/*
 * (C) Copyright 2013 Kurento (http://kurento.org/)
 *
 * 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.kurento.jsonrpc;

import static org.kurento.jsonrpc.internal.JsonRpcConstants.DATA_PROPERTY;
import static org.kurento.jsonrpc.internal.JsonRpcConstants.ERROR_PROPERTY;
import static org.kurento.jsonrpc.internal.JsonRpcConstants.ID_PROPERTY;
import static org.kurento.jsonrpc.internal.JsonRpcConstants.JSON_RPC_PROPERTY;
import static org.kurento.jsonrpc.internal.JsonRpcConstants.METHOD_PROPERTY;
import static org.kurento.jsonrpc.internal.JsonRpcConstants.PARAMS_PROPERTY;
import static org.kurento.jsonrpc.internal.JsonRpcConstants.RESULT_PROPERTY;
import static org.kurento.jsonrpc.internal.JsonRpcConstants.SESSION_ID_PROPERTY;

import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;

import org.kurento.jsonrpc.internal.JsonRpcConstants;
import org.kurento.jsonrpc.message.Message;
import org.kurento.jsonrpc.message.Request;
import org.kurento.jsonrpc.message.Response;
import org.kurento.jsonrpc.message.ResponseError;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import com.google.gson.JsonSyntaxException;
import com.google.gson.internal.$Gson$Types;

/**
 *
 * Gson/JSON utilities; used to serialise Java object to JSON (as String).
 *
 * @author Miguel París (mparisdiaz@gsyc.es)
 * @since 1.0.0
 */
public class JsonUtils {

  public static final boolean INJECT_SESSION_ID = true;
  /**
   * Static instance of Gson object.
   */
  private static Gson gson;

  /**
   * Serialise Java object to JSON (as String).
   *
   * @param obj
   *          Java Object representing a JSON message to be serialized
   * @return Serialised JSON message (as String)
   */
  public static String toJson(Object obj) {
    return getGson().toJson(obj);
  }

  public static JsonObject toJsonObject(Object obj) {
    // TODO Optimise this implementation if possible
    return fromJson(getGson().toJson(obj), JsonObject.class);
  }

  public static Message fromJsonMessage(String message) {
    JsonObject json = fromJson(message, JsonObject.class);
    if (json.has(METHOD_PROPERTY)) {
      return fromJsonRequest(json, JsonObject.class);
    } else {
      return fromJsonResponse(json, JsonElement.class);
    }
  }

  public static <T> Request<T> fromJsonRequest(String json, Class<T> paramsClass) {

    if (INJECT_SESSION_ID) {
      // TODO Optimise this implementation if possible
      return fromJsonRequestInject(fromJson(json, JsonObject.class), paramsClass);
    }

    return getGson().fromJson(json,
        $Gson$Types.newParameterizedTypeWithOwner(null, Request.class, paramsClass));
  }

  public static <T> Response<T> fromJsonResponse(String json, Class<T> resultClass) {

    if (INJECT_SESSION_ID) {
      // TODO Optimise this implementation if possible
      return fromJsonResponseInject(fromJson(json, JsonObject.class), resultClass);
    }
    try {
      return getGson().fromJson(json,
          $Gson$Types.newParameterizedTypeWithOwner(null, Response.class, resultClass));

    } catch (JsonSyntaxException e) {
      throw new JsonRpcException("Exception converting Json '" + json
          + "' to a JSON-RPC response with params as class " + resultClass.getName(), e);
    }
  }

  public static <T> Request<T> fromJsonRequest(JsonObject json, Class<T> paramsClass) {

    if (INJECT_SESSION_ID) {

      // TODO Optimise this implementation if possible
      return fromJsonRequestInject(json, paramsClass);

    }

    return getGson().fromJson(json,
        $Gson$Types.newParameterizedTypeWithOwner(null, Request.class, paramsClass));

  }

  public static <T> Response<T> fromJsonResponse(JsonObject json, Class<T> resultClass) {

    if (INJECT_SESSION_ID) {

      // TODO Optimize this implementation if possible
      return fromJsonResponseInject(json, resultClass);
    }

    return getGson().fromJson(json,
        $Gson$Types.newParameterizedTypeWithOwner(null, Response.class, resultClass));

  }

  private static <T> Response<T> fromJsonResponseInject(JsonObject jsonObject,
      Class<T> resultClass) {

    try {

      String sessionId = extractSessionId(jsonObject, RESULT_PROPERTY);

      Response<T> response;
      if (resultClass != null) {
        response = JsonUtils.fromJson(jsonObject,
            $Gson$Types.newParameterizedTypeWithOwner(null, Response.class, resultClass));
      } else {
        response = JsonUtils.fromJson(jsonObject,
            $Gson$Types.newParameterizedTypeWithOwner(null, Response.class, JsonElement.class));
      }

      response.setSessionId(sessionId);
      return response;

    } catch (JsonSyntaxException e) {
      throw new JsonRpcException("Exception converting Json '" + jsonObject
          + "' to a JSON-RPC response with params as class " + resultClass.getName(), e);
    }
  }

  private static <T> Request<T> fromJsonRequestInject(JsonObject jsonObject, Class<T> paramsClass) {

    String sessionId = extractSessionId(jsonObject, PARAMS_PROPERTY);
    Request<T> request = getGson().fromJson(jsonObject,
        $Gson$Types.newParameterizedTypeWithOwner(null, Request.class, paramsClass));

    request.setSessionId(sessionId);
    return request;
  }

  private static String extractSessionId(JsonObject jsonObject, String memberName) {
    JsonElement responseJson = jsonObject.get(memberName);

    if (responseJson != null && responseJson.isJsonObject()) {

      JsonObject responseJsonObject = (JsonObject) responseJson;

      JsonElement sessionIdJson = responseJsonObject.remove(SESSION_ID_PROPERTY);

      if (sessionIdJson != null && !(sessionIdJson instanceof JsonNull)) {
        return sessionIdJson.getAsString();
      }
    }
    return null;
  }

  public static String toJson(Object obj, Type type) {
    return getGson().toJson(obj, type);
  }

  public static <T> String toJsonRequest(Request<T> request) {
    return getGson().toJson(request, $Gson$Types.newParameterizedTypeWithOwner(null, Request.class,
        getClassOrNull(request.getParams())));
  }

  public static <T> String toJsonResponse(Response<T> request) {
    return getGson().toJson(request, $Gson$Types.newParameterizedTypeWithOwner(null, Response.class,
        getClassOrNull(request.getResult())));
  }

  public static <T> T fromJson(String json, Class<T> clazz) {
    return getGson().fromJson(json, clazz);
  }

  public static <T> T fromJson(JsonElement json, Class<T> clazz) {
    return getGson().fromJson(json, clazz);
  }

  public static <T> T fromJson(String json, Type type) {
    return getGson().fromJson(json, type);
  }

  public static <T> T fromJson(JsonElement json, Type type) {
    return getGson().fromJson(json, type);
  }

  private static Class<?> getClassOrNull(Object object) {
    return object == null ? null : object.getClass();
  }

  /**
   * Gson object accessor (getter).
   *
   * @return son object
   */
  public static Gson getGson() {

    if (gson == null) {
      synchronized (JsonUtils.class) {
        if (gson == null) {
          GsonBuilder builder = new GsonBuilder();
          builder.registerTypeAdapter(Request.class, new JsonRpcRequestDeserializer());

          builder.registerTypeAdapter(Response.class, new JsonRpcResponseDeserializer());

          builder.registerTypeAdapter(Props.class, new JsonPropsAdapter());

          builder.disableHtmlEscaping();

          gson = builder.create();
        }
      }
    }

    return gson;
  }

  static boolean isIn(JsonObject jObject, String[] clues) {
    for (String clue : clues) {
      if (jObject.has(clue)) {
        return true;
      }
    }
    return false;
  }

  public static String toJsonMessage(Message message) {

    if (message.getSessionId() != null && INJECT_SESSION_ID) {

      JsonObject jsonObject = JsonUtils.toJsonObject(message);

      JsonObject objectToInjectSessionId;
      if (message instanceof Request) {

        objectToInjectSessionId = convertToObject(jsonObject, PARAMS_PROPERTY);

      } else {

        Response<?> response = (Response<?>) message;
        if (response.getError() == null) {

          objectToInjectSessionId = convertToObject(jsonObject, RESULT_PROPERTY);
        } else {

          objectToInjectSessionId = convertToObject(jsonObject, ERROR_PROPERTY, DATA_PROPERTY);
        }
      }

      objectToInjectSessionId.addProperty(JsonRpcConstants.SESSION_ID_PROPERTY,
          message.getSessionId());

      return jsonObject.toString();
    }

    return JsonUtils.toJson(message);

  }

  private static JsonObject convertToObject(JsonObject jsonObject, String... properties) {

    String property = properties[0];

    JsonElement paramsJson = jsonObject.get(property);
    JsonObject paramsAsObject = null;

    if (paramsJson == null) {
      paramsAsObject = new JsonObject();
      jsonObject.add(property, paramsAsObject);
      paramsJson = paramsAsObject;
    }

    if (!paramsJson.isJsonObject()) {
      paramsAsObject = new JsonObject();
      paramsAsObject.add("value", paramsJson);
      jsonObject.add(property, paramsAsObject);
    } else {
      paramsAsObject = (JsonObject) paramsJson;
    }

    if (properties.length > 1) {
      convertToObject(jsonObject, Arrays.copyOfRange(properties, 1, properties.length));
    }

    return paramsAsObject;
  }

  public static JsonElement toJsonElement(Object object) {
    return getGson().toJsonTree(object);
  }

  public static <E> E extractJavaValueFromResult(JsonElement result, Type type) {

    if (type == Void.class || type == void.class) {
      return null;
    }

    JsonElement extractResult = extractJsonValueFromResponse(result, type);

    return JsonUtils.fromJson(extractResult, type);
  }

  private static JsonElement extractJsonValueFromResponse(JsonElement result, Type type) {

    if (result == null) {
      return null;
    }

    if (isPrimitiveClass(type) || isEnum(type)) {

      if (result instanceof JsonPrimitive) {
        return result;

      } else if (result instanceof JsonArray) {
        throw new JsonRpcException(
            "Json array '" + result + " cannot be converted to " + getTypeName(type));
      } else if (result instanceof JsonObject) {
        return extractSimpleValueFromJsonObject((JsonObject) result, type);
      } else {
        throw new JsonRpcException("Unrecognized json element: " + result);
      }

    } else if (isList(type)) {

      if (result instanceof JsonArray) {
        return result;
      }

      return extractSimpleValueFromJsonObject((JsonObject) result, type);
    } else {
      return result;
    }
  }

  private static JsonElement extractSimpleValueFromJsonObject(JsonObject result, Type type) {

    if (!result.has("value")) {
      throw new JsonRpcException("Json object " + result + " cannot be converted to "
          + getTypeName(type) + " without a 'value' property");
    }

    return result.get("value");
  }

  private static boolean isEnum(Type type) {

    if (type instanceof Class) {
      Class<?> clazz = (Class<?>) type;
      return clazz.isEnum();
    }

    return false;
  }

  private static boolean isPrimitiveClass(Type type) {
    return type == String.class
        || type == Void.class
        || type == void.class
        || type == Boolean.class
        || type == boolean.class
        || type == Integer.class
        || type == int.class
        || type == Long.class
        || type == long.class
        || type == Float.class
        || type == float.class
        || type == Double.class
        || type == double.class;
  }

  private static boolean isList(Type type) {

    if (type == List.class) {
      return true;
    }

    if (type instanceof ParameterizedType) {
      ParameterizedType pType = (ParameterizedType) type;
      if (pType.getRawType() instanceof Class) {
        return ((Class<?>) pType.getRawType()).isAssignableFrom(List.class);
      }
    }

    return false;
  }

  private static String getTypeName(Type type) {

    if (type instanceof Class) {

      Class<?> clazz = (Class<?>) type;
      return clazz.getSimpleName();

    } else if (type instanceof ParameterizedType) {

      StringBuilder sb = new StringBuilder();

      ParameterizedType pType = (ParameterizedType) type;
      Class<?> rawClass = (Class<?>) pType.getRawType();

      sb.append(rawClass.getSimpleName());

      Type[] arguments = pType.getActualTypeArguments();
      if (arguments.length > 0) {
        sb.append('<');
        for (Type aType : arguments) {
          sb.append(getTypeName(aType));
          sb.append(',');
        }
        sb.deleteCharAt(sb.length() - 1);
        sb.append('>');
      }

      return sb.toString();
    }

    return type.toString();
  }

  public static List<String> toStringList(JsonArray values) {
    List<String> list = new ArrayList<>();
    for (JsonElement element : values) {
      if (element instanceof JsonPrimitive) {
        list.add(((JsonPrimitive) element).getAsString());
      } else {
        throw new JsonParseException("JsonArray " + values + " contains non string elements");
      }
    }
    return list;
  }

}

class JsonRpcResponseDeserializer implements JsonDeserializer<Response<?>> {

  private static final Logger log = LoggerFactory.getLogger(JsonRpcResponseDeserializer.class);

  @Override
  public Response<?> deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
      throws JsonParseException {

    if (!(json instanceof JsonObject)) {
      throw new JsonParseException("JonObject expected, found " + json.getClass().getSimpleName());
    }

    JsonObject jObject = (JsonObject) json;

    if (!jObject.has(JSON_RPC_PROPERTY)) {
      throw new JsonParseException(
          "Invalid JsonRpc response lacking version '" + JSON_RPC_PROPERTY + "' field");
    }

    if (!jObject.get(JSON_RPC_PROPERTY).getAsString().equals(JsonRpcConstants.JSON_RPC_VERSION)) {
      throw new JsonParseException("Invalid JsonRpc version");
    }

    Integer id = null;
    JsonElement idAsJsonElement = jObject.get(ID_PROPERTY);
    if (idAsJsonElement != null) {
      try {
        id = Integer.valueOf(idAsJsonElement.getAsInt());
      } catch (Exception e) {
        throw new JsonParseException(
            "Invalid format in '" + ID_PROPERTY + "' field in request " + json);
      }
    }

    if (jObject.has(ERROR_PROPERTY)) {

      return new Response<>(id,
          (ResponseError) context.deserialize(jObject.get(ERROR_PROPERTY), ResponseError.class));

    } else {

      if (jObject.has(RESULT_PROPERTY)) {

        ParameterizedType parameterizedType = (ParameterizedType) typeOfT;
        Object deserialize = context.deserialize(jObject.get(RESULT_PROPERTY),
            parameterizedType.getActualTypeArguments()[0]);
        return new Response<>(id, deserialize);

      } else {

        log.warn("Invalid JsonRpc response: " + json + " It lacks a valid '" + RESULT_PROPERTY
            + "' or '" + ERROR_PROPERTY + "' field");

        return new Response<>(id, null);
      }
    }
  }
}

class JsonRpcRequestDeserializer implements JsonDeserializer<Request<?>> {

  @Override
  public Request<?> deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
      throws JsonParseException {

    if (!(json instanceof JsonObject)) {
      throw new JsonParseException(
          "Invalid JsonRpc request showning JsonElement type " + json.getClass().getSimpleName());
    }

    JsonObject jObject = (JsonObject) json;

    // FIXME: Enable again when KMS sends jsonrpc field in register message
    // if (!jObject.has(JSON_RPC_PROPERTY)) {
    // throw new JsonParseException(
    // "Invalid JsonRpc request lacking version '"
    // + JSON_RPC_PROPERTY + "' field");
    // }
    //
    // if (!jObject.get("jsonrpc").getAsString().equals(JSON_RPC_VERSION)) {
    // throw new JsonParseException("Invalid JsonRpc version");
    // }

    if (!jObject.has(METHOD_PROPERTY)) {
      throw new JsonParseException(
          "Invalid JsonRpc request lacking '" + METHOD_PROPERTY + "' field");
    }

    Integer id = null;
    if (jObject.has(ID_PROPERTY)) {
      id = Integer.valueOf(jObject.get(ID_PROPERTY).getAsInt());
    }

    ParameterizedType parameterizedType = (ParameterizedType) typeOfT;

    return new Request<>(id, jObject.get(METHOD_PROPERTY).getAsString(), context
        .deserialize(jObject.get(PARAMS_PROPERTY), parameterizedType.getActualTypeArguments()[0]));

  }
}

class JsonPropsAdapter implements JsonDeserializer<Props>, JsonSerializer<Props> {

  @Override
  public Props deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
      throws JsonParseException {

    if (!(json instanceof JsonObject)) {
      throw new JsonParseException("Cannot convert " + json + " to Props object");
    }

    JsonObject jObject = (JsonObject) json;

    Props props = new Props();
    for (Map.Entry<String, JsonElement> e : jObject.entrySet()) {
      Object value = deserialize(e.getValue(), context);
      props.add(e.getKey(), value);
    }
    return props;
  }

  private Object deserialize(JsonElement value, JsonDeserializationContext context) {

    if (value instanceof JsonObject) {
      return deserialize(value, null, context);

    } else if (value instanceof JsonPrimitive) {
      return toPrimitiveObject(value);

    } else if (value instanceof JsonArray) {

      JsonArray array = (JsonArray) value;
      List<Object> result = new ArrayList<>();
      for (JsonElement element : array) {
        result.add(deserialize(element, context));
      }
      return result;
    } else if (value instanceof JsonNull) {
      return null;
    } else {
      throw new JsonRpcException("Unrecognized Json element: " + value);
    }
  }

  public Object toPrimitiveObject(JsonElement element) {

    JsonPrimitive primitive = (JsonPrimitive) element;
    if (primitive.isBoolean()) {
      return Boolean.valueOf(primitive.getAsBoolean());
    } else if (primitive.isNumber()) {
      Number number = primitive.getAsNumber();
      double value = number.doubleValue();
      if ((int) value == value) {
        return Integer.valueOf((int) value);
      } else if ((long) value == value) {
        return Long.valueOf((long) value);
      } else if ((float) value == value) {
        return Float.valueOf((float) value);
      } else {
        return Double.valueOf((double) value);
      }
    } else if (primitive.isString()) {
      return primitive.getAsString();
    } else {
      throw new JsonRpcException("Unrecognized JsonPrimitive: " + primitive);
    }
  }

  @Override
  public JsonElement serialize(Props props, Type typeOfSrc, JsonSerializationContext context) {

    JsonObject jsonObject = new JsonObject();
    for (Prop prop : props) {
      jsonObject.add(prop.getName(), context.serialize(prop.getValue()));
    }

    return jsonObject;
  }

}
