package io.digdag.client.config;

import java.util.List;
import java.util.Map;
import java.util.Iterator;
import java.io.IOException;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.base.Optional;
import com.google.common.base.Throwables;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.RuntimeJsonMappingException;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.annotation.JacksonInject;
import com.fasterxml.jackson.core.type.TypeReference;
import static java.util.Locale.ENGLISH;

public class Config
{
    protected final ObjectMapper mapper;
    protected final ObjectNode object;

    Config(ObjectMapper mapper)
    {
        this(mapper, new ObjectNode(JsonNodeFactory.instance));
    }

    Config(ObjectMapper mapper, JsonNode object)
    {
        this.mapper = mapper;
        this.object = (ObjectNode) object;
    }

    protected Config(Config config)
    {
        this.mapper = config.mapper;
        this.object = config.object.deepCopy();
    }

    // here uses JsonNode instead of ObjectNode for workaround of https://github.com/FasterXML/jackson-databind/issues/941
    @JsonCreator
    public static Config deserializeFromJackson(@JacksonInject ObjectMapper mapper, JsonNode object)
    {
        if (!object.isObject()) {
            throw new RuntimeJsonMappingException("Expected object but got "+object);
        }
        return new Config(mapper, (ObjectNode) object);
    }

    @JsonValue
    public ObjectNode getInternalObjectNode()
    {
        return object;
    }

    public Config set(String key, Object v)
    {
        if (v == null) {
            remove(key);
        } else {
            setNode(key, writeObject(v));
        }
        return this;
    }

    public Config setOptional(String key, Optional<?> v)
    {
        if (v.isPresent()) {
            set(key, v.get());
        }
        return this;
    }

    public Config setIfNotSet(String key, Object v)
    {
        if (!has(key)) {
            if (v != null) {
                setNode(key, writeObject(v));
            }
        }
        return this;
    }

    public Config setNested(String key, Config v)
    {
        setNode(key, v.object);
        return this;
    }

    public Config setAll(Config other)
    {
        for (Map.Entry<String, JsonNode> field : other.getEntries()) {
            setNode(field.getKey(), field.getValue());
        }
        return this;
    }

    public Config setAllIfNotSet(Config other)
    {
        for (Map.Entry<String, JsonNode> field : other.getEntries()) {
            setIfNotSet(field.getKey(), field.getValue());
        }
        return this;
    }

    private Iterable<Map.Entry<String, JsonNode>> getEntries()
    {
        return new Iterable<Map.Entry<String, JsonNode>>() {
            public Iterator<Map.Entry<String, JsonNode>> iterator()
            {
                return object.fields();
            }
        };
    }

    public Config remove(String key)
    {
        object.remove(key);
        return this;
    }

    public Config deepCopy()
    {
        return new Config(this);
    }

    public Config merge(Config other)
    {
        mergeJsonObject(object, other.deepCopy().object);
        return this;
    }

    public Config mergeDefault(Config other)
    {
        mergeDefaultJsonObject(object, other.deepCopy().object);
        return this;
    }

    private static void mergeJsonObject(ObjectNode src, ObjectNode other)
    {
        Iterator<Map.Entry<String, JsonNode>> ite = other.fields();
        while (ite.hasNext()) {
            Map.Entry<String, JsonNode> pair = ite.next();
            JsonNode s = src.get(pair.getKey());
            JsonNode v = pair.getValue();
            if (v.isObject() && s != null && s.isObject()) {
                mergeJsonObject((ObjectNode) s, (ObjectNode) v);
            } else {
                src.set(pair.getKey(), v);  // keeps order if key exists
            }
        }
    }

    private static void mergeDefaultJsonObject(ObjectNode src, ObjectNode other)
    {
        Iterator<Map.Entry<String, JsonNode>> ite = other.fields();
        while (ite.hasNext()) {
            Map.Entry<String, JsonNode> pair = ite.next();
            JsonNode s = src.get(pair.getKey());
            JsonNode v = pair.getValue();
            if (v.isObject() && s != null && s.isObject()) {
                mergeDefaultJsonObject((ObjectNode) s, (ObjectNode) v);
            } else if (s == null) {
                src.set(pair.getKey(), v);
            }
        }
    }

    //private static void mergeJsonArray(ArrayNode src, ArrayNode other)
    //{
    //    for (int i=0; i < other.size(); i++) {
    //        JsonNode s = src.get(i);
    //        JsonNode v = other.get(i);
    //        if (s == null) {
    //            src.add(v);
    //        } else if (v.isObject() && s.isObject()) {
    //            mergeJsonObject((ObjectNode) s, (ObjectNode) v);
    //        } else if (v.isArray() && s.isArray()) {
    //            mergeJsonArray((ArrayNode) s, (ArrayNode) v);
    //        } else {
    //            src.remove(i);
    //            src.insert(i, v);
    //        }
    //    }
    //}

    private JsonNode writeObject(Object obj)
    {
        try {
            String value = mapper.writeValueAsString(obj);
            return mapper.readTree(value);
        }
        catch (Exception ex) {
            throw Throwables.propagate(ex);
        }
    }

    public ConfigFactory getFactory()
    {
        return new ConfigFactory(mapper);
    }

    public List<String> getKeys()
    {
        return ImmutableList.copyOf(object.fieldNames());
    }

    public boolean isEmpty()
    {
        return !object.fieldNames().hasNext();
    }

    public boolean has(String key)
    {
        return object.has(key);
    }

    public <E> E convert(Class<E> type)
    {
        return readObject(type, object, null);
    }

    public <E> E get(String key, Class<E> type)
    {
        JsonNode value = getNode(key);
        if (value == null) {
            throw new ConfigException("Parameter '"+key+"' is required but not set");
        }
        else if (value.isNull()) {
            throw new ConfigException("Parameter '"+key+"' is required but null");
        }
        return readObject(type, value, key);
    }

    public Object get(String key, JavaType type)
    {
        JsonNode value = getNode(key);
        if (value == null) {
            throw new ConfigException("Parameter '"+key+"' is required but not set");
        }
        else if (value.isNull()) {
            throw new ConfigException("Parameter '"+key+"' is required but null");
        }
        return readObject(type, value, key);
    }

    @SuppressWarnings("unchecked")
    public <E> E get(String key, TypeReference<E> type)
    {
        return (E) get(key, mapper.getTypeFactory().constructType(type));
    }

    public <E> E get(String key, Class<E> type, E defaultValue)
    {
        JsonNode value = getNode(key);
        if (value == null || value.isNull()) {
            return defaultValue;
        }
        return readObject(type, value, key);
    }

    public Object get(String key, JavaType type, Object defaultValue)
    {
        JsonNode value = getNode(key);
        if (value == null || value.isNull()) {
            return defaultValue;
        }
        return readObject(type, value, key);
    }

    @SuppressWarnings("unchecked")
    public <E> E get(String key, TypeReference<E> type, E defaultValue)
    {
        return (E) get(key, mapper.getTypeFactory().constructType(type), defaultValue);
    }

    @SuppressWarnings("unchecked")
    public <E> Optional<E> getOptional(String key, Class<E> type)
    {
        return (Optional<E>) get(key,
                mapper.getTypeFactory().constructReferenceType(Optional.class, mapper.getTypeFactory().constructType(type)),
                Optional.<E>absent());
    }

    @SuppressWarnings("unchecked")
    public <E> Optional<E> getOptional(String key, TypeReference<E> type)
    {
        return (Optional<E>) get(key, mapper.getTypeFactory().constructType(type), Optional.<E>absent());
    }

    @SuppressWarnings("unchecked")
    public <E> List<E> getList(String key, Class<E> elementType)
    {
        return (List<E>) get(key, mapper.getTypeFactory().constructCollectionType(List.class, elementType));
    }

    @SuppressWarnings("unchecked")
    public <E> List<E> getListOrEmpty(String key, Class<E> elementType)
    {
        return (List<E>) get(key, mapper.getTypeFactory().constructCollectionType(List.class, elementType), ImmutableList.<E>of());
    }

    @SuppressWarnings("unchecked")
    public <E> List<E> parseList(String key, Class<E> elementType)
    {
        JsonNode parsed = tryParseNested(key);
        if (parsed == null) {
            return getList(key, elementType);
        }
        else {
            return (List<E>) readObject(mapper.getTypeFactory().constructCollectionType(List.class, elementType), parsed, key);
        }
    }

    @SuppressWarnings("unchecked")
    public <E> List<E> parseListOrGetEmpty(String key, Class<E> elementType)
    {
        JsonNode parsed = tryParseNested(key);
        if (parsed == null) {
            return getListOrEmpty(key, elementType);
        }
        else {
            return (List<E>) readObject(mapper.getTypeFactory().constructCollectionType(List.class, elementType), parsed, key);
        }
    }

    @SuppressWarnings("unchecked")
    public <K, V> Map<K, V> getMap(String key, Class<K> keyType, Class<V> valueType)
    {
        return (Map<K, V>) get(key, mapper.getTypeFactory().constructParametrizedType(Map.class, Map.class, keyType, valueType));
    }

    @SuppressWarnings("unchecked")
    public <K, V> Map<K, V> getMapOrEmpty(String key, Class<K> keyType, Class<V> valueType)
    {
        return (Map<K, V>) get(key, mapper.getTypeFactory().constructParametrizedType(Map.class, Map.class, keyType, valueType), ImmutableMap.<K, V>of());
    }

    public Config getNested(String key)
    {
        JsonNode value = getNode(key);
        if (value == null) {
            throw new ConfigException("Parameter '"+key+"' is required but not set");
        }
        if (!value.isObject()) {
            throw new ConfigException("Parameter '"+key+"' must be an object");
        }
        return new Config(mapper, (ObjectNode) value);
    }

    public Config parseNested(String key)
    {
        JsonNode parsed = tryParseNested(key);
        if (parsed == null) {
            return getNested(key);
        }
        else {
            if (!parsed.isObject()) {
                throw new ConfigException("Parameter '"+key+"' must be an object");
            }
            return new Config(mapper, (ObjectNode) parsed);
        }
    }

    public Config parseNestedOrGetEmpty(String key)
    {
        JsonNode parsed = tryParseNested(key);
        if (parsed == null) {
            return getNestedOrGetEmpty(key);
        }
        else {
            if (!parsed.isObject()) {
                throw new ConfigException("Parameter '"+key+"' must be an object");
            }
            return new Config(mapper, (ObjectNode) parsed);
        }
    }

    private JsonNode tryParseNested(String key)
    {
        JsonNode node = get(key, JsonNode.class, null);
        if (node == null) {
            return null;
        }
        else if (node.isTextual()) {
            try {
                return mapper.readTree(node.textValue());
            }
            catch (IOException ex) {
                throw new ConfigException(ex);
            }
        }
        else {
            return null;
        }
    }

    public Config getNestedOrSetEmpty(String key)
    {
        JsonNode value = getNode(key);
        if (value == null || value.isNull()) {
            value = newObjectNode();
            setNode(key, value);
        }
        else if (!value.isObject()) {
            throw new ConfigException("Parameter '"+key+"' must be an object");
        }
        return new Config(mapper, (ObjectNode) value);
    }

    public Config getNestedOrGetEmpty(String key)
    {
        JsonNode value = getNode(key);
        if (value == null || value.isNull()) {
            value = newObjectNode();
        }
        else if (!value.isObject()) {
            throw new ConfigException("Parameter '"+key+"' must be an object");
        }
        return new Config(mapper, (ObjectNode) value);
    }

    public Config getNestedOrderedOrGetEmpty(String key)
    {
        JsonNode value = getNode(key);
        if (value == null) {
            value = newObjectNode();
        }
        else if (value.isArray()) {
            Config config = new Config(mapper);
            Iterator<JsonNode> ite = ((ArrayNode) value).elements();
            while (ite.hasNext()) {
                JsonNode nested = ite.next();
                if (!(nested instanceof ObjectNode)) {
                    throw new RuntimeJsonMappingException("Expected object but got "+nested);
                }
                // here assumes config is an order-preserving map
                config.setAll(new Config(mapper, (ObjectNode) nested));
            }
            return config;
        }
        else if (!value.isObject()) {
            throw new ConfigException("Parameter '"+key+"' must be an object or array of objects");
        }
        return new Config(mapper, (ObjectNode) value);
    }

    public Optional<Config> getOptionalNested(String key)
    {
        JsonNode value = getNode(key);
        if (value == null || value.isNull()) {
            return Optional.absent();
        }
        return Optional.of(getNested(key));
    }

    private ObjectNode newObjectNode()
    {
        return object.objectNode();
    }

    protected JsonNode getNode(String key)
    {
        return object.get(key);
    }

    protected void setNode(String key, JsonNode value)
    {
        object.set(key, value);
    }

    private <E> E readObject(Class<E> type, JsonNode value, String key)
    {
        try {
            return mapper.readValue(value.traverse(), type);
        }
        catch (Exception ex) {
            throw propagateConvertException(ex, typeNameOf(type), value, key);
        }
    }

    private Object readObject(JavaType type, JsonNode value, String key)
    {
        try {
            return mapper.readValue(value.traverse(), type);
        }
        catch (Exception ex) {
            throw propagateConvertException(ex, typeNameOf(type), value, key);
        }
    }

    private ConfigException propagateConvertException(Exception ex, String typeName, JsonNode value, String key)
    {
        Throwables.propagateIfInstanceOf(ex, ConfigException.class);
        String message = String.format(ENGLISH, "Expected %s for key '%s' but got %s (%s)",
                typeName, key, jsonSample(value), typeNameOf(value));
        return new ConfigException(message, ex);
    }

    private static String typeNameOf(Class<?> type)
    {
        if (type.equals(String.class)) {
            return "string type";
        }
        else if (type.equals(int.class) || type.equals(Integer.class)) {
            return "integer (int) type";
        }
        else if (type.equals(long.class) || type.equals(Long.class)) {
            return "integer (long) type";
        }
        else if (type.equals(boolean.class) || type.equals(Boolean.class)) {
            return "'true' or 'false'";
        }
        return type.toString();
    }

    private static String typeNameOf(JavaType type)
    {
        if (List.class.isAssignableFrom(type.getRawClass())) {
            return "array type";
        }
        else if (Map.class.isAssignableFrom(type.getRawClass())) {
            return "object type";
        }
        return type.toString();
    }

    private static String typeNameOf(JsonNode value)
    {
        switch (value.getNodeType()) {
        case NULL:
            return "null";
        case BOOLEAN:
            return "boolean";
        case NUMBER:
            return "number";
        case ARRAY:
            return "array";
        case OBJECT:
            return "object";
        case STRING:
            return "string";
        case BINARY:
            return "binary";
        case POJO:
        case MISSING:
        default:
            return value.getNodeType().toString();
        }
    }

    private String jsonSample(JsonNode value)
    {
        String json = value.toString();
        if (json.length() < 100) {
            return json;
        }
        else {
            return json.substring(0, 97) + "...";
        }
    }

    @Override
    public String toString()
    {
        return object.toString();
    }

    @Override
    public boolean equals(Object other)
    {
        if (!(other instanceof Config)) {
            return false;
        }
        return object.equals(((Config) other).object);
    }

    @Override
    public int hashCode()
    {
        return object.hashCode();
    }
}