/*
 *  Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you 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 groovy.json;

import groovy.lang.Closure;
import groovy.lang.MetaBeanProperty;
import groovy.lang.MetaProperty;
import groovy.util.Expando;
import org.apache.groovy.json.internal.CharBuf;
import org.apache.groovy.json.internal.Chr;

import java.io.File;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.net.URL;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.UUID;

import static groovy.json.JsonOutput.CLOSE_BRACE;
import static groovy.json.JsonOutput.CLOSE_BRACKET;
import static groovy.json.JsonOutput.COMMA;
import static groovy.json.JsonOutput.EMPTY_LIST_CHARS;
import static groovy.json.JsonOutput.EMPTY_MAP_CHARS;
import static groovy.json.JsonOutput.EMPTY_STRING_CHARS;
import static groovy.json.JsonOutput.OPEN_BRACE;
import static groovy.json.JsonOutput.OPEN_BRACKET;
import static java.lang.reflect.Modifier.isPublic;
import static java.lang.reflect.Modifier.isStatic;

/**
 * A JsonGenerator that can be configured with various {@link JsonGenerator.Options}.
 * If the default options are sufficient consider using the static {@code JsonOutput.toJson}
 * methods.
 *
 * @see JsonGenerator.Options#build()
 * @since 2.5.0
 */
public class DefaultJsonGenerator implements JsonGenerator {

    protected final boolean excludeNulls;
    protected final boolean disableUnicodeEscaping;
    protected final String dateFormat;
    protected final Locale dateLocale;
    protected final TimeZone timezone;
    protected final Set<Converter> converters = new LinkedHashSet<Converter>();
    protected final Set<String> excludedFieldNames = new HashSet<String>();
    protected final Set<Class<?>> excludedFieldTypes = new HashSet<Class<?>>();

    protected DefaultJsonGenerator(Options options) {
        excludeNulls = options.excludeNulls;
        disableUnicodeEscaping = options.disableUnicodeEscaping;
        dateFormat = options.dateFormat;
        dateLocale = options.dateLocale;
        timezone = options.timezone;
        if (!options.converters.isEmpty()) {
            converters.addAll(options.converters);
        }
        if (!options.excludedFieldNames.isEmpty()) {
            excludedFieldNames.addAll(options.excludedFieldNames);
        }
        if (!options.excludedFieldTypes.isEmpty()) {
            excludedFieldTypes.addAll(options.excludedFieldTypes);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String toJson(Object object) {
        CharBuf buffer = CharBuf.create(255);
        writeObject(object, buffer);
        return buffer.toString();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean isExcludingFieldsNamed(String name) {
        return excludedFieldNames.contains(name);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean isExcludingValues(Object value) {
        if (value == null) {
            return excludeNulls;
        } else {
            return shouldExcludeType(value.getClass());
        }
    }

    /**
     * Serializes Number value and writes it into specified buffer.
     */
    protected void writeNumber(Class<?> numberClass, Number value, CharBuf buffer) {
        if (numberClass == Integer.class) {
            buffer.addInt((Integer) value);
        } else if (numberClass == Long.class) {
            buffer.addLong((Long) value);
        } else if (numberClass == BigInteger.class) {
            buffer.addBigInteger((BigInteger) value);
        } else if (numberClass == BigDecimal.class) {
            buffer.addBigDecimal((BigDecimal) value);
        } else if (numberClass == Double.class) {
            Double doubleValue = (Double) value;
            if (doubleValue.isInfinite()) {
                throw new JsonException("Number " + value + " can't be serialized as JSON: infinite are not allowed in JSON.");
            }
            if (doubleValue.isNaN()) {
                throw new JsonException("Number " + value + " can't be serialized as JSON: NaN are not allowed in JSON.");
            }

            buffer.addDouble(doubleValue);
        } else if (numberClass == Float.class) {
            Float floatValue = (Float) value;
            if (floatValue.isInfinite()) {
                throw new JsonException("Number " + value + " can't be serialized as JSON: infinite are not allowed in JSON.");
            }
            if (floatValue.isNaN()) {
                throw new JsonException("Number " + value + " can't be serialized as JSON: NaN are not allowed in JSON.");
            }

            buffer.addFloat(floatValue);
        } else if (numberClass == Byte.class) {
            buffer.addByte((Byte) value);
        } else if (numberClass == Short.class) {
            buffer.addShort((Short) value);
        } else { // Handle other Number implementations
            buffer.addString(value.toString());
        }
    }

    protected void writeObject(Object object, CharBuf buffer) {
        writeObject(null, object, buffer);
    }

    /**
     * Serializes object and writes it into specified buffer.
     */
    protected void writeObject(String key, Object object, CharBuf buffer) {

        if (isExcludingValues(object)) {
            return;
        }

        if (object == null) {
            buffer.addNull();
            return;
        }

        Class<?> objectClass = object.getClass();

        Converter converter = findConverter(objectClass);
        if (converter != null) {
            object = converter.convert(object, key);
            objectClass = object.getClass();
        }

        if (CharSequence.class.isAssignableFrom(objectClass)) { // Handle String, StringBuilder, GString and other CharSequence implementations
            writeCharSequence((CharSequence) object, buffer);
        } else if (objectClass == Boolean.class) {
            buffer.addBoolean((Boolean) object);
        } else if (Number.class.isAssignableFrom(objectClass)) {
            writeNumber(objectClass, (Number) object, buffer);
        } else if (Date.class.isAssignableFrom(objectClass)) {
            writeDate((Date) object, buffer);
        } else if (Calendar.class.isAssignableFrom(objectClass)) {
            writeDate(((Calendar) object).getTime(), buffer);
        } else if (Map.class.isAssignableFrom(objectClass)) {
            writeMap((Map) object, buffer);
        } else if (Iterable.class.isAssignableFrom(objectClass)) {
            writeIterator(((Iterable<?>) object).iterator(), buffer);
        } else if (Iterator.class.isAssignableFrom(objectClass)) {
            writeIterator((Iterator) object, buffer);
        } else if (objectClass == Character.class) {
            buffer.addJsonEscapedString(Chr.array((Character) object), disableUnicodeEscaping);
        } else if (objectClass == URL.class) {
            buffer.addJsonEscapedString(object.toString(), disableUnicodeEscaping);
        } else if (objectClass == UUID.class) {
            buffer.addQuoted(object.toString());
        } else if (objectClass == JsonOutput.JsonUnescaped.class) {
            buffer.add(object.toString());
        } else if (Closure.class.isAssignableFrom(objectClass)) {
            writeMap(JsonDelegate.cloneDelegateAndGetContent((Closure<?>) object), buffer);
        } else if (Expando.class.isAssignableFrom(objectClass)) {
            writeMap(((Expando) object).getProperties(), buffer);
        } else if (Enumeration.class.isAssignableFrom(objectClass)) {
            List<?> list = Collections.list((Enumeration<?>) object);
            writeIterator(list.iterator(), buffer);
        } else if (objectClass.isArray()) {
            writeArray(objectClass, object, buffer);
        } else if (Enum.class.isAssignableFrom(objectClass)) {
            buffer.addQuoted(((Enum<?>) object).name());
        } else if (File.class.isAssignableFrom(objectClass)) {
            Map<?, ?> properties = getObjectProperties(object);
            //Clean up all recursive references to File objects
            properties.entrySet().removeIf(entry -> entry.getValue() instanceof File);
            writeMap(properties, buffer);
        } else {
            Map<?, ?> properties = getObjectProperties(object);
            writeMap(properties, buffer);
        }
    }

    protected Map<?, ?> getObjectProperties(final Object object) {
        List<MetaProperty> metaProperties = org.codehaus.groovy.runtime.InvokerHelper.getMetaClass(object).getProperties();

        Map<String, Object> namesAndValues = new LinkedHashMap<>(metaProperties.size());

        for (MetaProperty mp : metaProperties) {
            if (!isPublic(mp.getModifiers())) continue; // GROOVY-5169
            if ( isStatic(mp.getModifiers())) continue; // GROOVY-7682

            // skip write-only property: see File
            if (mp instanceof MetaBeanProperty) {
                MetaBeanProperty mbp = (MetaBeanProperty) mp;
                if (mbp.getField() == null && mbp.getGetter() == null) continue;
            }

            String name = mp.getName();
            if ("class".equals(name) || "metaClass".equals(name) || "declaringClass".equals(name)) continue;

            namesAndValues.put(name, mp.getProperty(object));
        }

        return namesAndValues;
    }

    /**
     * Serializes any char sequence and writes it into specified buffer.
     */
    protected void writeCharSequence(CharSequence seq, CharBuf buffer) {
        if (seq.length() > 0) {
            buffer.addJsonEscapedString(seq.toString(), disableUnicodeEscaping);
        } else {
            buffer.addChars(EMPTY_STRING_CHARS);
        }
    }

    /**
     * Serializes any char sequence and writes it into specified buffer
     * without performing any manipulation of the given text.
     */
    protected void writeRaw(CharSequence seq, CharBuf buffer) {
        if (seq != null) {
            buffer.add(seq.toString());
        }
    }

    /**
     * Serializes date and writes it into specified buffer.
     */
    protected void writeDate(Date date, CharBuf buffer) {
        SimpleDateFormat formatter = new SimpleDateFormat(dateFormat, dateLocale);
        formatter.setTimeZone(timezone);
        buffer.addQuoted(formatter.format(date));
    }

    /**
     * Serializes array and writes it into specified buffer.
     */
    protected void writeArray(Class<?> arrayClass, Object array, CharBuf buffer) {
        if (Object[].class.isAssignableFrom(arrayClass)) {
            Object[] objArray = (Object[]) array;
            writeIterator(Arrays.asList(objArray).iterator(), buffer);
            return;
        }
        buffer.addChar(OPEN_BRACKET);
        if (int[].class.isAssignableFrom(arrayClass)) {
            int[] intArray = (int[]) array;
            if (intArray.length > 0) {
                buffer.addInt(intArray[0]);
                for (int i = 1; i < intArray.length; i++) {
                    buffer.addChar(COMMA).addInt(intArray[i]);
                }
            }
        } else if (long[].class.isAssignableFrom(arrayClass)) {
            long[] longArray = (long[]) array;
            if (longArray.length > 0) {
                buffer.addLong(longArray[0]);
                for (int i = 1; i < longArray.length; i++) {
                    buffer.addChar(COMMA).addLong(longArray[i]);
                }
            }
        } else if (boolean[].class.isAssignableFrom(arrayClass)) {
            boolean[] booleanArray = (boolean[]) array;
            if (booleanArray.length > 0) {
                buffer.addBoolean(booleanArray[0]);
                for (int i = 1; i < booleanArray.length; i++) {
                    buffer.addChar(COMMA).addBoolean(booleanArray[i]);
                }
            }
        } else if (char[].class.isAssignableFrom(arrayClass)) {
            char[] charArray = (char[]) array;
            if (charArray.length > 0) {
                buffer.addJsonEscapedString(Chr.array(charArray[0]), disableUnicodeEscaping);
                for (int i = 1; i < charArray.length; i++) {
                    buffer.addChar(COMMA).addJsonEscapedString(Chr.array(charArray[i]), disableUnicodeEscaping);
                }
            }
        } else if (double[].class.isAssignableFrom(arrayClass)) {
            double[] doubleArray = (double[]) array;
            if (doubleArray.length > 0) {
                buffer.addDouble(doubleArray[0]);
                for (int i = 1; i < doubleArray.length; i++) {
                    buffer.addChar(COMMA).addDouble(doubleArray[i]);
                }
            }
        } else if (float[].class.isAssignableFrom(arrayClass)) {
            float[] floatArray = (float[]) array;
            if (floatArray.length > 0) {
                buffer.addFloat(floatArray[0]);
                for (int i = 1; i < floatArray.length; i++) {
                    buffer.addChar(COMMA).addFloat(floatArray[i]);
                }
            }
        } else if (byte[].class.isAssignableFrom(arrayClass)) {
            byte[] byteArray = (byte[]) array;
            if (byteArray.length > 0) {
                buffer.addByte(byteArray[0]);
                for (int i = 1; i < byteArray.length; i++) {
                    buffer.addChar(COMMA).addByte(byteArray[i]);
                }
            }
        } else if (short[].class.isAssignableFrom(arrayClass)) {
            short[] shortArray = (short[]) array;
            if (shortArray.length > 0) {
                buffer.addShort(shortArray[0]);
                for (int i = 1; i < shortArray.length; i++) {
                    buffer.addChar(COMMA).addShort(shortArray[i]);
                }
            }
        }
        buffer.addChar(CLOSE_BRACKET);
    }

    /**
     * Serializes map and writes it into specified buffer.
     */
    protected void writeMap(Map<?, ?> map, CharBuf buffer) {
        if (map.isEmpty()) {
            buffer.addChars(EMPTY_MAP_CHARS);
            return;
        }
        buffer.addChar(OPEN_BRACE);
        for (Map.Entry<?, ?> entry : map.entrySet()) {
            if (entry.getKey() == null) {
                throw new IllegalArgumentException("Maps with null keys can\'t be converted to JSON");
            }
            String key = entry.getKey().toString();
            Object value = entry.getValue();
            if (isExcludingValues(value) || isExcludingFieldsNamed(key)) {
                continue;
            }
            writeMapEntry(key, value, buffer);
            buffer.addChar(COMMA);
        }
        buffer.removeLastChar(COMMA); // dangling comma
        buffer.addChar(CLOSE_BRACE);
    }

    /**
     * Serializes a map entry and writes it into specified buffer.
     */
    protected void writeMapEntry(String key, Object value, CharBuf buffer) {
        buffer.addJsonFieldName(key, disableUnicodeEscaping);
        writeObject(key, value, buffer);
    }

    /**
     * Serializes iterator and writes it into specified buffer.
     */
    protected void writeIterator(Iterator<?> iterator, CharBuf buffer) {
        if (!iterator.hasNext()) {
            buffer.addChars(EMPTY_LIST_CHARS);
            return;
        }
        buffer.addChar(OPEN_BRACKET);
        while (iterator.hasNext()) {
            Object it = iterator.next();
            if (!isExcludingValues(it)) {
                writeObject(it, buffer);
                buffer.addChar(COMMA);
            }
        }
        buffer.removeLastChar(COMMA); // dangling comma
        buffer.addChar(CLOSE_BRACKET);
    }

    /**
     * Finds a converter that can handle the given type.  The first converter
     * that reports it can handle the type is returned, based on the order in
     * which the converters were specified.  A {@code null} value will be returned
     * if no suitable converter can be found for the given type.
     *
     * @param type that this converter can handle
     * @return first converter that can handle the given type; else {@code null}
     *         if no compatible converters are found for the given type.
     */
    protected Converter findConverter(Class<?> type) {
        for (Converter c : converters) {
            if (c.handles(type)) {
                return c;
            }
        }
        return null;
    }

    /**
     * Indicates whether the given type should be excluded from the generated output.
     *
     * @param type the type to check
     * @return {@code true} if the given type should not be output, else {@code false}
     */
    protected boolean shouldExcludeType(Class<?> type) {
        for (Class<?> t : excludedFieldTypes) {
            if (t.isAssignableFrom(type)) {
                return true;
            }
        }
        return false;
    }

    /**
     * A converter that handles converting a given type using a closure.
     *
     * @since 2.5.0
     */
    protected static class ClosureConverter implements Converter {

        protected final Class<?> type;
        protected final Closure<?> closure;
        protected final int paramCount;

        protected ClosureConverter(Class<?> type, Closure<?> closure) {
            if (type == null) {
                throw new NullPointerException("Type parameter must not be null");
            }
            if (closure == null) {
                throw new NullPointerException("Closure parameter must not be null");
            }

            int paramCount = closure.getMaximumNumberOfParameters();
            if (paramCount < 1) {
                throw new IllegalArgumentException("Closure must accept at least one parameter");
            }
            Class<?> param1 = closure.getParameterTypes()[0];
            if (!param1.isAssignableFrom(type)) {
                throw new IllegalArgumentException("Expected first parameter to be of type: " + type.toString());
            }
            if (paramCount > 1) {
                Class<?> param2 = closure.getParameterTypes()[1];
                if (!param2.isAssignableFrom(String.class)) {
                    throw new IllegalArgumentException("Expected second parameter to be of type: " + String.class.toString());
                }
            }
            this.type = type;
            this.closure = closure;
            this.paramCount = paramCount;
        }

        /**
         * Returns {@code true} if this converter can handle conversions
         * of the given type.
         *
         * @param type the type of the object to convert
         * @return true if this converter can successfully convert values of
         *      the given type
         */
        @Override
        public boolean handles(Class<?> type) {
            return this.type.isAssignableFrom(type);
        }

        /**
         * Converts a given value.
         *
         * @param value the object to convert
         * @param key the key name for the value, may be {@code null}
         * @return the converted object
         */
        @Override
        public Object convert(Object value, String key) {
            return (paramCount == 1) ?
                    closure.call(value) :
                    closure.call(value, key);
        }

        /**
         * Any two Converter instances registered for the same type are considered
         * to be equal.  This comparison makes managing instances in a Set easier;
         * since there is no chaining of Converters it makes sense to only allow
         * one per type.
         *
         * @param o the object with which to compare.
         * @return {@code true} if this object contains the same class; {@code false} otherwise.
         */
        @Override
        public boolean equals(Object o) {
            if (o == this) {
                return true;
            }
            if (!(o instanceof ClosureConverter)) {
                return false;
            }
            return this.type == ((ClosureConverter)o).type;
        }

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

        @Override
        public String toString() {
            return super.toString() + "<" + this.type.toString() + ">";
        }
    }
}
