/*
 * Copyright (c) 2010, 2013, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Oracle designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Oracle in the LICENSE file that accompanied this code.
 *
 * This code is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */

package jdk.nashorn.internal.objects;

import static jdk.nashorn.internal.runtime.ECMAErrors.typeError;
import static jdk.nashorn.internal.runtime.ScriptRuntime.UNDEFINED;

import java.lang.invoke.MethodHandle;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Callable;
import jdk.nashorn.api.scripting.JSObject;
import jdk.nashorn.api.scripting.ScriptObjectMirror;
import jdk.nashorn.internal.objects.annotations.Attribute;
import jdk.nashorn.internal.objects.annotations.Function;
import jdk.nashorn.internal.objects.annotations.ScriptClass;
import jdk.nashorn.internal.objects.annotations.Where;
import jdk.nashorn.internal.runtime.ConsString;
import jdk.nashorn.internal.runtime.JSONFunctions;
import jdk.nashorn.internal.runtime.JSType;
import jdk.nashorn.internal.runtime.PropertyMap;
import jdk.nashorn.internal.runtime.ScriptObject;
import jdk.nashorn.internal.runtime.arrays.ArrayLikeIterator;
import jdk.nashorn.internal.runtime.linker.Bootstrap;
import jdk.nashorn.internal.runtime.linker.InvokeByName;

/**
 * ECMAScript 262 Edition 5, Section 15.12 The NativeJSON Object
 *
 */
@ScriptClass("JSON")
public final class NativeJSON extends ScriptObject {
    private static final Object TO_JSON = new Object();

    private static InvokeByName getTO_JSON() {
        return Global.instance().getInvokeByName(TO_JSON,
                new Callable<InvokeByName>() {
                    @Override
                    public InvokeByName call() {
                        return new InvokeByName("toJSON", ScriptObject.class, Object.class, Object.class);
                    }
                });
    }

    private static final Object JSOBJECT_INVOKER = new Object();

    private static MethodHandle getJSOBJECT_INVOKER() {
        return Global.instance().getDynamicInvoker(JSOBJECT_INVOKER,
                new Callable<MethodHandle>() {
                    @Override
                    public MethodHandle call() {
                        return Bootstrap.createDynamicCallInvoker(Object.class, Object.class, Object.class);
                    }
                });
    }

    private static final Object REPLACER_INVOKER = new Object();

    private static MethodHandle getREPLACER_INVOKER() {
        return Global.instance().getDynamicInvoker(REPLACER_INVOKER,
                new Callable<MethodHandle>() {
                    @Override
                    public MethodHandle call() {
                        return Bootstrap.createDynamicCallInvoker(Object.class,
                            Object.class, Object.class, Object.class, Object.class);
                    }
                });
    }

    // initialized by nasgen
    @SuppressWarnings("unused")
    private static PropertyMap $nasgenmap$;

    private NativeJSON() {
        // don't create me!!
        throw new UnsupportedOperationException();
    }

    /**
     * ECMA 15.12.2 parse ( text [ , reviver ] )
     *
     * @param self     self reference
     * @param text     a JSON formatted string
     * @param reviver  optional value: function that takes two parameters (key, value)
     *
     * @return an ECMA script value
     */
    @Function(attributes = Attribute.NOT_ENUMERABLE, where = Where.CONSTRUCTOR)
    public static Object parse(final Object self, final Object text, final Object reviver) {
        return JSONFunctions.parse(text, reviver);
    }

    /**
     * ECMA 15.12.3 stringify ( value [ , replacer [ , space ] ] )
     *
     * @param self     self reference
     * @param value    ECMA script value (usually object or array)
     * @param replacer either a function or an array of strings and numbers
     * @param space    optional parameter - allows result to have whitespace injection
     *
     * @return a string in JSON format
     */
    @Function(attributes = Attribute.NOT_ENUMERABLE, where = Where.CONSTRUCTOR)
    public static Object stringify(final Object self, final Object value, final Object replacer, final Object space) {
        // The stringify method takes a value and an optional replacer, and an optional
        // space parameter, and returns a JSON text. The replacer can be a function
        // that can replace values, or an array of strings that will select the keys.

        // A default replacer method can be provided. Use of the space parameter can
        // produce text that is more easily readable.

        final StringifyState state = new StringifyState();

        // If there is a replacer, it must be a function or an array.
        if (Bootstrap.isCallable(replacer)) {
            state.replacerFunction = replacer;
        } else if (isArray(replacer) ||
                isJSObjectArray(replacer) ||
                replacer instanceof Iterable ||
                (replacer != null && replacer.getClass().isArray())) {

            state.propertyList = new ArrayList<>();

            final Iterator<Object> iter = ArrayLikeIterator.arrayLikeIterator(replacer);

            while (iter.hasNext()) {
                String item = null;
                final Object v = iter.next();

                if (v instanceof String) {
                    item = (String) v;
                } else if (v instanceof ConsString) {
                    item = v.toString();
                } else if (v instanceof Number ||
                        v instanceof NativeNumber ||
                        v instanceof NativeString) {
                    item = JSType.toString(v);
                }

                if (item != null) {
                    state.propertyList.add(item);
                }
            }
        }

        // If the space parameter is a number, make an indent
        // string containing that many spaces.

        String gap;

        // modifiable 'space' - parameter is final
        Object modSpace = space;
        if (modSpace instanceof NativeNumber) {
            modSpace = JSType.toNumber(JSType.toPrimitive(modSpace, Number.class));
        } else if (modSpace instanceof NativeString) {
            modSpace = JSType.toString(JSType.toPrimitive(modSpace, String.class));
        }

        if (modSpace instanceof Number) {
            final int indent = Math.min(10, JSType.toInteger(modSpace));
            if (indent < 1) {
                gap = "";
            } else {
                final StringBuilder sb = new StringBuilder();
                for (int i = 0; i < indent; i++) {
                    sb.append(' ');
                }
                gap = sb.toString();
            }
        } else if (JSType.isString(modSpace)) {
            final String str = modSpace.toString();
            gap = str.substring(0, Math.min(10, str.length()));
        } else {
            gap = "";
        }

        state.gap = gap;

        final ScriptObject wrapper = Global.newEmptyInstance();
        wrapper.set("", value, 0);

        return str("", wrapper, state);
    }

    // -- Internals only below this point

    // stringify helpers.

    private static class StringifyState {
        final Map<Object, Object> stack = new IdentityHashMap<>();

        StringBuilder  indent = new StringBuilder();
        String         gap = "";
        List<String>   propertyList = null;
        Object         replacerFunction = null;
    }

    // Spec: The abstract operation Str(key, holder).
    private static Object str(final Object key, final Object holder, final StringifyState state) {
        assert holder instanceof ScriptObject || holder instanceof JSObject;

        Object value = getProperty(holder, key);
        try {
            if (value instanceof ScriptObject) {
                final InvokeByName toJSONInvoker = getTO_JSON();
                final ScriptObject svalue = (ScriptObject)value;
                final Object toJSON = toJSONInvoker.getGetter().invokeExact(svalue);
                if (Bootstrap.isCallable(toJSON)) {
                    value = toJSONInvoker.getInvoker().invokeExact(toJSON, svalue, key);
                }
            } else if (value instanceof JSObject) {
                final JSObject jsObj = (JSObject)value;
                final Object toJSON = jsObj.getMember("toJSON");
                if (Bootstrap.isCallable(toJSON)) {
                    value = getJSOBJECT_INVOKER().invokeExact(toJSON, value);
                }
            }

            if (state.replacerFunction != null) {
                value = getREPLACER_INVOKER().invokeExact(state.replacerFunction, holder, key, value);
            }
        } catch(Error|RuntimeException t) {
            throw t;
        } catch(final Throwable t) {
            throw new RuntimeException(t);
        }
        final boolean isObj = (value instanceof ScriptObject);
        if (isObj) {
            if (value instanceof NativeNumber) {
                value = JSType.toNumber(value);
            } else if (value instanceof NativeString) {
                value = JSType.toString(value);
            } else if (value instanceof NativeBoolean) {
                value = ((NativeBoolean)value).booleanValue();
            }
        }

        if (value == null) {
            return "null";
        } else if (Boolean.TRUE.equals(value)) {
            return "true";
        } else if (Boolean.FALSE.equals(value)) {
            return "false";
        }

        if (value instanceof String) {
            return JSONFunctions.quote((String)value);
        } else if (value instanceof ConsString) {
            return JSONFunctions.quote(value.toString());
        }

        if (value instanceof Number) {
            return JSType.isFinite(((Number)value).doubleValue()) ? JSType.toString(value) : "null";
        }

        final JSType type = JSType.of(value);
        if (type == JSType.OBJECT) {
            if (isArray(value) || isJSObjectArray(value)) {
                return JA(value, state);
            } else if (value instanceof ScriptObject || value instanceof JSObject) {
                return JO(value, state);
            }
        }

        return UNDEFINED;
    }

    // Spec: The abstract operation JO(value) serializes an object.
    private static String JO(final Object value, final StringifyState state) {
        assert value instanceof ScriptObject || value instanceof JSObject;

        if (state.stack.containsKey(value)) {
            throw typeError("JSON.stringify.cyclic");
        }

        state.stack.put(value, value);
        final StringBuilder stepback = new StringBuilder(state.indent.toString());
        state.indent.append(state.gap);

        final StringBuilder finalStr = new StringBuilder();
        final List<Object>  partial  = new ArrayList<>();
        final List<String>  k        = state.propertyList == null ?
                Arrays.asList(getOwnKeys(value)) : state.propertyList;

        for (final Object p : k) {
            final Object strP = str(p, value, state);

            if (strP != UNDEFINED) {
                final StringBuilder member = new StringBuilder();

                member.append(JSONFunctions.quote(p.toString())).append(':');
                if (!state.gap.isEmpty()) {
                    member.append(' ');
                }

                member.append(strP);
                partial.add(member);
            }
        }

        if (partial.isEmpty()) {
            finalStr.append("{}");
        } else {
            if (state.gap.isEmpty()) {
                final int size = partial.size();
                int       index = 0;

                finalStr.append('{');

                for (final Object str : partial) {
                    finalStr.append(str);
                    if (index < size - 1) {
                        finalStr.append(',');
                    }
                    index++;
                }

                finalStr.append('}');
            } else {
                final int size  = partial.size();
                int       index = 0;

                finalStr.append("{\n");
                finalStr.append(state.indent);

                for (final Object str : partial) {
                    finalStr.append(str);
                    if (index < size - 1) {
                        finalStr.append(",\n");
                        finalStr.append(state.indent);
                    }
                    index++;
                }

                finalStr.append('\n');
                finalStr.append(stepback);
                finalStr.append('}');
            }
        }

        state.stack.remove(value);
        state.indent = stepback;

        return finalStr.toString();
    }

    // Spec: The abstract operation JA(value) serializes an array.
    private static Object JA(final Object value, final StringifyState state) {
        assert value instanceof ScriptObject || value instanceof JSObject;

        if (state.stack.containsKey(value)) {
            throw typeError("JSON.stringify.cyclic");
        }

        state.stack.put(value, value);
        final StringBuilder stepback = new StringBuilder(state.indent.toString());
        state.indent.append(state.gap);
        final List<Object> partial = new ArrayList<>();

        final int length = JSType.toInteger(getLength(value));
        int index = 0;

        while (index < length) {
            Object strP = str(index, value, state);
            if (strP == UNDEFINED) {
                strP = "null";
            }
            partial.add(strP);
            index++;
        }

        final StringBuilder finalStr = new StringBuilder();
        if (partial.isEmpty()) {
            finalStr.append("[]");
        } else {
            if (state.gap.isEmpty()) {
                final int size = partial.size();
                index = 0;
                finalStr.append('[');
                for (final Object str : partial) {
                    finalStr.append(str);
                    if (index < size - 1) {
                        finalStr.append(',');
                    }
                    index++;
                }

                finalStr.append(']');
            } else {
                final int size = partial.size();
                index = 0;
                finalStr.append("[\n");
                finalStr.append(state.indent);
                for (final Object str : partial) {
                    finalStr.append(str);
                    if (index < size - 1) {
                        finalStr.append(",\n");
                        finalStr.append(state.indent);
                    }
                    index++;
                }

                finalStr.append('\n');
                finalStr.append(stepback);
                finalStr.append(']');
            }
        }

        state.stack.remove(value);
        state.indent = stepback;

        return finalStr.toString();
    }

    private static String[] getOwnKeys(final Object obj) {
        if (obj instanceof ScriptObject) {
            return ((ScriptObject)obj).getOwnKeys(false);
        } else if (obj instanceof ScriptObjectMirror) {
            return ((ScriptObjectMirror)obj).getOwnKeys(false);
        } else if (obj instanceof JSObject) {
            // No notion of "own keys" or "proto" for general JSObject! We just
            // return all keys of the object. This will be useful for POJOs
            // implementing JSObject interface.
            return ((JSObject)obj).keySet().toArray(new String[0]);
        } else {
            throw new AssertionError("should not reach here");
        }
    }

    private static Object getLength(final Object obj) {
        if (obj instanceof ScriptObject) {
            return ((ScriptObject)obj).getLength();
        } else if (obj instanceof JSObject) {
            return ((JSObject)obj).getMember("length");
        } else {
            throw new AssertionError("should not reach here");
        }
    }

    private static boolean isJSObjectArray(final Object obj) {
        return (obj instanceof JSObject) && ((JSObject)obj).isArray();
    }

    private static Object getProperty(final Object holder, final Object key) {
        if (holder instanceof ScriptObject) {
            return ((ScriptObject)holder).get(key);
        } else if (holder instanceof JSObject) {
            final JSObject jsObj = (JSObject)holder;
            if (key instanceof Integer) {
                return jsObj.getSlot((Integer)key);
            } else {
                return jsObj.getMember(Objects.toString(key));
            }
        } else {
            return new AssertionError("should not reach here");
        }
    }
}
