package co.omise;

import co.omise.models.OmiseList;
import co.omise.models.OmiseObject;
import co.omise.models.Params;
import co.omise.requests.RequestBuilder;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.joda.JodaModule;
import com.fasterxml.jackson.datatype.joda.cfg.JacksonJodaDateFormat;
import com.fasterxml.jackson.datatype.joda.ser.DateTimeSerializer;
import com.fasterxml.jackson.datatype.joda.ser.LocalDateSerializer;
import org.joda.time.DateTime;
import org.joda.time.LocalDate;
import org.joda.time.format.DateTimeFormatter;
import org.joda.time.format.ISODateTimeFormat;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Map;

/**
 * Serializer wraps Jackson's {@link ObjectMapper} and provides a
 * one-stop shop for handling Omise API models serialization.
 * <p>
 * Use the {@link #defaultSerializer()} method to obtain an instance.
 * </p>
 *
 * @see ObjectMapper
 */
public final class Serializer {
    private static Serializer defaultInstance;

    /**
     * The default Serializer instance.
     *
     * @return The default {@link Serializer} instance.
     */
    public static Serializer defaultSerializer() {
        if (defaultInstance == null) {
            defaultInstance = new Serializer();
        }

        return defaultInstance;
    }


    private final ObjectMapper objectMapper;
    private final DateTimeFormatter dateTimeFormatter;
    private final DateTimeFormatter localDateFormatter;

    private Serializer() {
        dateTimeFormatter = ISODateTimeFormat.dateTimeNoMillis();
        localDateFormatter = ISODateTimeFormat.date();

        objectMapper = new ObjectMapper()
                .registerModule(new JodaModule()
                        .addSerializer(DateTime.class, new DateTimeSerializer()
                                .withFormat(new JacksonJodaDateFormat(dateTimeFormatter), 0)
                        )
                        .addSerializer(LocalDate.class, new LocalDateSerializer()
                                .withFormat(new JacksonJodaDateFormat(localDateFormatter), 0)
                        )
                )

                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
                .configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE, true)
                .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
                .configure(SerializationFeature.WRITE_NULL_MAP_VALUES, false); // TODO: Deprecate in vNext
    }

    /**
     * Returns the pre-configured {@link ObjectMapper} used for
     * serializing and deserializing Omise API objects.
     *
     * @return An {@link ObjectMapper} instance.
     */
    public ObjectMapper objectMapper() {
        return objectMapper;
    }

    /**
     * Returns the pre-configured {@link DateTimeFormatter} used for
     * serializing and deserializing date and times for Omise API objects.
     *
     * @return A {@link DateTimeFormatter} instance.
     */
    public DateTimeFormatter dateTimeFormatter() {
        return dateTimeFormatter;
    }

    /**
     * Returns the pre-configured {@link DateTimeFormatter} used for
     * serializing and deserializing date for Omise API objects.
     *
     * @return A {@link DateTimeFormatter} instance.
     */
    public DateTimeFormatter localDateFormatter() {
        return localDateFormatter;
    }

    /**
     * Deserialize an instance of the given class from the input stream.
     *
     * @param input The {@link InputStream} that contains the data to deserialize.
     * @param klass The {@link Class} to deserialize the result into.
     * @param <T>   The type to deserialize the result into.
     * @return An instance of type T deserialized from the input stream.
     * @throws IOException on general I/O error.
     */
    public <T extends OmiseObject> T deserialize(InputStream input, Class<T> klass) throws IOException {
        return objectMapper.readerFor(klass).readValue(input);
    }

    /**
     * Deserialize an instance of the given type reference from the input stream.
     *
     * @param input The {@link InputStream} that contains the data to deserialize.
     * @param ref   The {@link TypeReference} of the type to deserialize the result into.
     * @param <T>   The type to deserialize the result into.
     * @return An instance of the given type T deserialized from the input stream.
     * @throws IOException on general I/O error.
     */
    public <T extends OmiseObject> T deserialize(InputStream input, TypeReference<T> ref) throws IOException {
        return objectMapper.readerFor(ref).readValue(input);
    }

    /**
     * Deserialize an instance of the given class from the map.
     *
     * @param map   The {@link Map} containing the JSON structure to deserialize from.
     * @param klass The {@link Class} to deserialize the result into.
     * @param <T>   The type to deserialize the result into.
     * @return An instance of type T deserialized from the map.
     */
    public <T extends OmiseObject> T deserializeFromMap(Map<String, Object> map, Class<T> klass) {
        return objectMapper.convertValue(map, klass);
    }

    /**
     * Deserialize an instance of the given type reference from the map.
     *
     * @param map The {@link Map} containing the JSON structure to deserialize from.
     * @param ref The {@link TypeReference} of the type to deserialize the result into.
     * @param <T> The type to deserialize the result into.
     * @return An instance of the given type T deserialized from the map.
     */
    public <T extends OmiseObject> T deserializeFromMap(Map<String, Object> map, TypeReference<T> ref) {
        return objectMapper.convertValue(map, ref);
    }

    /**
     * Serializes the given model to the output stream.
     *
     * @param output The {@link OutputStream} to serializes the model into.
     * @param model  The {@link OmiseObject} to serialize.
     * @param <T>    The type of the model to serialize.
     * @throws IOException on general I/O error.
     */
    public <T extends OmiseObject> void serialize(OutputStream output, T model) throws IOException {
        objectMapper.writerFor(model.getClass()).writeValue(output, model);
    }

    /**
     * Serializes the given parameter object to the output stream.
     *
     * @param output The {@link OutputStream} to serialize the parameter into.
     * @param param  The {@link Params} to serialize.
     * @param <T>    The type of the parameter object to serialize.
     * @throws IOException on general I/O error.
     */
    public <T extends Params> void serializeParams(OutputStream output, T param) throws IOException {
        // TODO: Add params-specific options.
        objectMapper.writerFor(param.getClass()).writeValue(output, param);
    }

    /**
     * Serialize the given model to a map with JSON-like structure.
     *
     * @param model The {@link OmiseObject} to serialize.
     * @param <T>   The type of the model to serialize.
     * @return The map containing the model's data.
     */
    public <T extends OmiseObject> Map<String, Object> serializeToMap(T model) {
        return objectMapper.convertValue(model, new TypeReference<Map<String, Object>>() {
        });
    }

    /**
     * Serialize the given model to a representation suitable for using as URL query parameters.
     *
     * @param value The value to serialize
     * @param <T>   The type of the value to serialize.
     * @return The string value for using as query parameters.
     */
    public <T extends Enum<T>> String serializeToQueryParams(T value) {
        return (String) objectMapper.convertValue(value, String.class);
    }

    /**
     * Deserialize an instance of the given type reference from the input stream, used for deserializing lists.
     *
     * @param input The {@link InputStream} that contains the data to deserialize.
     * @param ref   The {@link TypeReference} of the type to deserialize the result into.
     * @param <T>   The type to deserialize the result into.
     * @return An instance of the given type T deserialized from the input stream.
     * @throws IOException on general I/O error.
     */
    public <T extends OmiseList> T deserializeList(InputStream input, TypeReference<T> ref) throws IOException {
        return objectMapper.readerFor(ref).readValue(input);
    }

    /**
     * Serializes the given {@link RequestBuilder} object to the provided output stream.
     *
     * @param outputStream The {@link OutputStream} to serialize the parameter into.
     * @param builder      The {@link RequestBuilder} to serialize.
     * @param <T>          The type of the parameter object to serialize.
     * @throws IOException on general I/O error.
     */
    public <T extends RequestBuilder> void serializeRequestBuilder(OutputStream outputStream, T builder) throws IOException {
        objectMapper.writerFor(builder.getClass()).writeValue(outputStream, builder);
    }
}