/*
 * Copyright (C) 2011 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 com.google.gson.graph;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.InstanceCreator;
import com.google.gson.JsonElement;
import com.google.gson.TypeAdapter;
import com.google.gson.TypeAdapterFactory;
import com.google.gson.internal.ConstructorConstructor;
import com.google.gson.internal.ObjectConstructor;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter;
import java.io.IOException;
import java.lang.reflect.Type;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.LinkedList;
import java.util.Map;
import java.util.Queue;

/**
 * Writes a graph of objects as a list of named nodes.
 */
// TODO: proper documentation
@SuppressWarnings("rawtypes")
public final class GraphAdapterBuilder {
  private final Map<Type, InstanceCreator<?>> instanceCreators;
  private final ConstructorConstructor constructorConstructor;

  public GraphAdapterBuilder() {
      this.instanceCreators = new HashMap<Type, InstanceCreator<?>>();
      this.constructorConstructor = new ConstructorConstructor(instanceCreators);
  }
  public GraphAdapterBuilder addType(Type type) {
    final ObjectConstructor<?> objectConstructor = constructorConstructor.get(TypeToken.get(type));
    InstanceCreator<Object> instanceCreator = new InstanceCreator<Object>() {
      public Object createInstance(Type type) {
        return objectConstructor.construct();
      }
    };
    return addType(type, instanceCreator);
  }

  public GraphAdapterBuilder addType(Type type, InstanceCreator<?> instanceCreator) {
    if (type == null || instanceCreator == null) {
      throw new NullPointerException();
    }
    instanceCreators.put(type, instanceCreator);
    return this;
  }

  public void registerOn(GsonBuilder gsonBuilder) {
    Factory factory = new Factory(instanceCreators);
    gsonBuilder.registerTypeAdapterFactory(factory);
    for (Map.Entry<Type, InstanceCreator<?>> entry : instanceCreators.entrySet()) {
      gsonBuilder.registerTypeAdapter(entry.getKey(), factory);
    }
  }

  static class Factory implements TypeAdapterFactory, InstanceCreator {
    private final Map<Type, InstanceCreator<?>> instanceCreators;
    private final ThreadLocal<Graph> graphThreadLocal = new ThreadLocal<Graph>();

    Factory(Map<Type, InstanceCreator<?>> instanceCreators) {
      this.instanceCreators = instanceCreators;
    }

    public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
      if (!instanceCreators.containsKey(type.getType())) {
        return null;
      }

      final TypeAdapter<T> typeAdapter = gson.getDelegateAdapter(this, type);
      final TypeAdapter<JsonElement> elementAdapter = gson.getAdapter(JsonElement.class);
      return new TypeAdapter<T>() {
        @Override public void write(JsonWriter out, T value) throws IOException {
          if (value == null) {
            out.nullValue();
            return;
          }

          Graph graph = graphThreadLocal.get();
          boolean writeEntireGraph = false;

          /*
           * We have one of two cases:
           *  1. We've encountered the first known object in this graph. Write
           *     out the graph, starting with that object.
           *  2. We've encountered another graph object in the course of #1.
           *     Just write out this object's name. We'll circle back to writing
           *     out the object's value as a part of #1.
           */

          if (graph == null) {
            writeEntireGraph = true;
            graph = new Graph(new IdentityHashMap<Object, Element<?>>());
          }

          @SuppressWarnings("unchecked") // graph.map guarantees consistency between value and T
          Element<T> element = (Element<T>) graph.map.get(value);
          if (element == null) {
            element = new Element<T>(value, graph.nextName(), typeAdapter, null);
            graph.map.put(value, element);
            graph.queue.add(element);
          }

          if (writeEntireGraph) {
            graphThreadLocal.set(graph);
            try {
              out.beginObject();
              Element<?> current;
              while ((current = graph.queue.poll()) != null) {
                out.name(current.id);
                current.write(out);
              }
              out.endObject();
            } finally {
              graphThreadLocal.remove();
            }
          } else {
            out.value(element.id);
          }
        }

        @Override public T read(JsonReader in) throws IOException {
          if (in.peek() == JsonToken.NULL) {
            in.nextNull();
            return null;
          }

          /*
           * Again we have one of two cases:
           *  1. We've encountered the first known object in this graph. Read
           *     the entire graph in as a map from names to their JsonElements.
           *     Then convert the first JsonElement to its Java object.
           *  2. We've encountered another graph object in the course of #1.
           *     Read in its name, then deserialize its value from the
           *     JsonElement in our map. We need to do this lazily because we
           *     don't know which TypeAdapter to use until a value is
           *     encountered in the wild.
           */

          String currentName = null;
          Graph graph = graphThreadLocal.get();
          boolean readEntireGraph = false;

          if (graph == null) {
            graph = new Graph(new HashMap<Object, Element<?>>());
            readEntireGraph = true;

            // read the entire tree into memory
            in.beginObject();
            while (in.hasNext()) {
              String name = in.nextName();
              if (currentName == null) {
                currentName = name;
              }
              JsonElement element = elementAdapter.read(in);
              graph.map.put(name, new Element<T>(null, name, typeAdapter, element));
            }
            in.endObject();
          } else {
            currentName = in.nextString();
          }

          if (readEntireGraph) {
            graphThreadLocal.set(graph);
          }
          try {
            @SuppressWarnings("unchecked") // graph.map guarantees consistency between value and T
            Element<T> element = (Element<T>) graph.map.get(currentName);
            // now that we know the typeAdapter for this name, go from JsonElement to 'T'
            if (element.value == null) {
              element.typeAdapter = typeAdapter;
              element.read(graph);
            }
            return element.value;
          } finally {
            if (readEntireGraph) {
              graphThreadLocal.remove();
            }
          }
        }
      };
    }

    /**
     * Hook for the graph adapter to get a reference to a deserialized value
     * before that value is fully populated. This is useful to deserialize
     * values that directly or indirectly reference themselves: we can hand
     * out an instance before read() returns.
     *
     * <p>Gson should only ever call this method when we're expecting it to;
     * that is only when we've called back into Gson to deserialize a tree.
     */
    @SuppressWarnings("unchecked")
    public Object createInstance(Type type) {
      Graph graph = graphThreadLocal.get();
      if (graph == null || graph.nextCreate == null) {
        throw new IllegalStateException("Unexpected call to createInstance() for " + type);
      }
      InstanceCreator<?> creator = instanceCreators.get(type);
      Object result = creator.createInstance(type);
      graph.nextCreate.value = result;
      graph.nextCreate = null;
      return result;
    }
  }

  static class Graph {
    /**
     * The graph elements. On serialization keys are objects (using an identity
     * hash map) and on deserialization keys are the string names (using a
     * standard hash map).
     */
    private final Map<Object, Element<?>> map;

    /**
     * The queue of elements to write during serialization. Unused during
     * deserialization.
     */
    private final Queue<Element> queue = new LinkedList<Element>();

    /**
     * The instance currently being deserialized. Used as a backdoor between
     * the graph traversal (which needs to know instances) and instance creators
     * which create them.
     */
    private Element nextCreate;

    private Graph(Map<Object, Element<?>> map) {
      this.map = map;
    }

    /**
     * Returns a unique name for an element to be inserted into the graph.
     */
    public String nextName() {
      return "0x" + Integer.toHexString(map.size() + 1);
    }
  }

  /**
   * An element of the graph during serialization or deserialization.
   */
  static class Element<T> {
    /**
     * This element's name in the top level graph object.
     */
    private final String id;

    /**
     * The value if known. During deserialization this is lazily populated.
     */
    private T value;

    /**
     * This element's type adapter if known. During deserialization this is
     * lazily populated.
     */
    private TypeAdapter<T> typeAdapter;

    /**
     * The element to deserialize. Unused in serialization.
     */
    private final JsonElement element;

    Element(T value, String id, TypeAdapter<T> typeAdapter, JsonElement element) {
      this.value = value;
      this.id = id;
      this.typeAdapter = typeAdapter;
      this.element = element;
    }

    void write(JsonWriter out) throws IOException {
      typeAdapter.write(out, value);
    }

    void read(Graph graph) throws IOException {
      if (graph.nextCreate != null) {
        throw new IllegalStateException("Unexpected recursive call to read() for " + id);
      }
      graph.nextCreate = this;
      value = typeAdapter.fromJsonTree(element);
      if (value == null) {
        throw new IllegalStateException("non-null value deserialized to null: " + element);
      }
    }
  }
}