/*
 * Copyright (C) 2010 Google Inc.
 *
 * 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.mcteam.factions.gson;

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

/**
 * Adapts maps containing complex keys as arrays of map entries.
 *
 * <h3>Maps as JSON objects</h3>
 * The standard GSON map type adapter converts Java {@link Map Maps} to JSON
 * Objects. This requires that map keys can be serialized as strings; this is
 * insufficient for some key types. For example, consider a map whose keys are
 * points on a grid. The default JSON form encodes reasonably: <pre>   {@code
 *   Map<Point, String> original = new LinkedHashMap<Point, String>();
 *   original.put(new Point(5, 6), "a");
 *   original.put(new Point(8, 8), "b");
 *   System.out.println(gson.toJson(original, type));
 * }</pre>
 * The above code prints this JSON object:<pre>   {@code
 *   {
 *     "(5,6)": "a",
 *     "(8,8)": "b"
 *   }
 * }</pre>
 * But GSON is unable to deserialize this value because the JSON string name is
 * just the {@link Object#toString() toString()} of the map key. Attempting to
 * convert the above JSON to an object fails with a parse exception:
 * <pre>com.bukkit.mcteam.gson.JsonParseException: Expecting object found: "(5,6)"
 *   at com.bukkit.mcteam.gson.JsonObjectDeserializationVisitor.visitFieldUsingCustomHandler
 *   at com.bukkit.mcteam.gson.ObjectNavigator.navigateClassFields
 *   ...</pre>
 *
 * <h3>Maps as JSON arrays</h3>
 * An alternative approach taken by this type adapter is to encode maps as
 * arrays of map entries. Each map entry is a two element array containing a key
 * and a value. This approach is more flexible because any type can be used as
 * the map's key; not just strings. But it's also less portable because the
 * receiver of such JSON must be aware of the map entry convention.
 *
 * <p>Register this adapter when you are creating your GSON instance.
 * <pre>   {@code
 *   Gson gson = new GsonBuilder()
 *     .registerTypeAdapter(Map.class, new MapAsArrayTypeAdapter())
 *     .create();
 * }</pre>
 * This will change the structure of the JSON emitted by the code above. Now we
 * get an array. In this case the arrays elements are map entries:
 * <pre>   {@code
 *   [
 *     [
 *       {
 *         "x": 5,
 *         "y": 6
 *       },
 *       "a",
 *     ],
 *     [
 *       {
 *         "x": 8,
 *         "y": 8
 *       },
 *       "b"
 *     ]
 *   ]
 * }</pre>
 * This format will serialize and deserialize just fine as long as this adapter
 * is registered.
 *
 * <p>This adapter returns regular JSON objects for maps whose keys are not
 * complex. A key is complex if its JSON-serialized form is an array or an
 * object.
 */
public final class MapAsArrayTypeAdapter
    implements JsonSerializer<Map<?, ?>>, JsonDeserializer<Map<?, ?>> {

  public Map<?, ?> deserialize(JsonElement json, Type typeOfT,
      JsonDeserializationContext context) throws JsonParseException {
    Map<Object, Object> result = new LinkedHashMap<Object, Object>();
    Type[] keyAndValueType = typeToTypeArguments(typeOfT);
    if (json.isJsonArray()) {
      JsonArray array = json.getAsJsonArray();
      for (int i = 0; i < array.size(); i++) {
        JsonArray entryArray = array.get(i).getAsJsonArray();
        Object k = context.deserialize(entryArray.get(0), keyAndValueType[0]);
        Object v = context.deserialize(entryArray.get(1), keyAndValueType[1]);
        result.put(k, v);
      }
      checkSize(array, array.size(), result, result.size());
    } else {
      JsonObject object = json.getAsJsonObject();
      for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
        Object k = context.deserialize(new JsonPrimitive(entry.getKey()), keyAndValueType[0]);
        Object v = context.deserialize(entry.getValue(), keyAndValueType[1]);
        result.put(k, v);
      }
      checkSize(object, object.entrySet().size(), result, result.size());
    }
    return result;
  }

  public JsonElement serialize(Map<?, ?> src, Type typeOfSrc, JsonSerializationContext context) {
    Type[] keyAndValueType = typeToTypeArguments(typeOfSrc);
    boolean serializeAsArray = false;
    List<JsonElement> keysAndValues = new ArrayList<JsonElement>();
    for (Map.Entry<?, ?> entry : src.entrySet()) {
      JsonElement key = context.serialize(entry.getKey(), keyAndValueType[0]);
      serializeAsArray |= key.isJsonObject() || key.isJsonArray();
      keysAndValues.add(key);
      keysAndValues.add(context.serialize(entry.getValue(), keyAndValueType[1]));
    }

    if (serializeAsArray) {
      JsonArray result = new JsonArray();
      for (int i = 0; i < keysAndValues.size(); i+=2) {
        JsonArray entryArray = new JsonArray();
        entryArray.add(keysAndValues.get(i));
        entryArray.add(keysAndValues.get(i + 1));
        result.add(entryArray);
      }
      return result;
    } else {
      JsonObject result = new JsonObject();
      for (int i = 0; i < keysAndValues.size(); i+=2) {
        result.add(keysAndValues.get(i).getAsString(), keysAndValues.get(i + 1));
      }
      checkSize(src, src.size(), result, result.entrySet().size());
      return result;
    }
  }

  private Type[] typeToTypeArguments(Type typeOfT) {
    if (typeOfT instanceof ParameterizedType) {
      Type[] typeArguments = ((ParameterizedType) typeOfT).getActualTypeArguments();
      if (typeArguments.length != 2) {
        throw new IllegalArgumentException("MapAsArrayTypeAdapter cannot handle " + typeOfT);
      }
      return typeArguments;
    }
    return new Type[] { Object.class, Object.class };
  }

  private void checkSize(Object input, int inputSize, Object output, int outputSize) {
    if (inputSize != outputSize) {
      throw new JsonSyntaxException("Input size " + inputSize + " != output size " + outputSize
          + " for input " + input + " and output " + output);
    }
  }
}
