/* -*- Mode: java; tab-width: 4; indent-tabs-mode: 1; c-basic-offset: 4 -*-
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.javascript.json;

import java.util.ArrayList;
import java.util.List;
import org.mozilla.javascript.Context;
import org.mozilla.javascript.ScriptRuntime;
import org.mozilla.javascript.ScriptRuntime.StringIdOrIndex;
import org.mozilla.javascript.Scriptable;

/**
 * This class converts a stream of JSON tokens into a JSON value.
 *
 * <p>See ECMA 15.12.
 *
 * @author Raphael Speyer
 * @author Hannes Wallnoefer
 */
public class JsonParser {

    private Context cx;
    private Scriptable scope;

    private int pos;
    private int length;
    private String src;

    public JsonParser(Context cx, Scriptable scope) {
        this.cx = cx;
        this.scope = scope;
    }

    public synchronized Object parseValue(String json) throws ParseException {
        if (json == null) {
            throw new ParseException("Input string may not be null");
        }
        pos = 0;
        length = json.length();
        src = json;
        Object value = readValue();
        consumeWhitespace();
        if (pos < length) {
            throw new ParseException("Expected end of stream at char " + pos);
        }
        return value;
    }

    private Object readValue() throws ParseException {
        consumeWhitespace();
        while (pos < length) {
            char c = src.charAt(pos++);
            switch (c) {
                case '{':
                    return readObject();
                case '[':
                    return readArray();
                case 't':
                    return readTrue();
                case 'f':
                    return readFalse();
                case '"':
                    return readString();
                case 'n':
                    return readNull();
                case '1':
                case '2':
                case '3':
                case '4':
                case '5':
                case '6':
                case '7':
                case '8':
                case '9':
                case '0':
                case '-':
                    return readNumber(c);
                default:
                    throw new ParseException("Unexpected token: " + c);
            }
        }
        throw new ParseException("Empty JSON string");
    }

    private Object readObject() throws ParseException {
        consumeWhitespace();
        Scriptable object = cx.newObject(scope);
        // handle empty object literal case early
        if (pos < length && src.charAt(pos) == '}') {
            pos += 1;
            return object;
        }
        String id;
        Object value;
        boolean needsComma = false;
        while (pos < length) {
            char c = src.charAt(pos++);
            switch (c) {
                case '}':
                    if (!needsComma) {
                        throw new ParseException("Unexpected comma in object literal");
                    }
                    return object;
                case ',':
                    if (!needsComma) {
                        throw new ParseException("Unexpected comma in object literal");
                    }
                    needsComma = false;
                    break;
                case '"':
                    if (needsComma) {
                        throw new ParseException("Missing comma in object literal");
                    }
                    id = readString();
                    consume(':');
                    value = readValue();

                    StringIdOrIndex indexObj = ScriptRuntime.toStringIdOrIndex(id);
                    if (indexObj.getStringId() == null) {
                        object.put(indexObj.getIndex(), object, value);
                    } else {
                        object.put(indexObj.getStringId(), object, value);
                    }
                    needsComma = true;
                    break;
                default:
                    throw new ParseException("Unexpected token in object literal");
            }
            consumeWhitespace();
        }
        throw new ParseException("Unterminated object literal");
    }

    private Object readArray() throws ParseException {
        consumeWhitespace();
        // handle empty array literal case early
        if (pos < length && src.charAt(pos) == ']') {
            pos += 1;
            return cx.newArray(scope, 0);
        }
        List<Object> list = new ArrayList<>();
        boolean needsComma = false;
        while (pos < length) {
            char c = src.charAt(pos);
            switch (c) {
                case ']':
                    if (!needsComma) {
                        throw new ParseException("Unexpected comma in array literal");
                    }
                    pos += 1;
                    return cx.newArray(scope, list.toArray());
                case ',':
                    if (!needsComma) {
                        throw new ParseException("Unexpected comma in array literal");
                    }
                    needsComma = false;
                    pos += 1;
                    break;
                default:
                    if (needsComma) {
                        throw new ParseException("Missing comma in array literal");
                    }
                    list.add(readValue());
                    needsComma = true;
            }
            consumeWhitespace();
        }
        throw new ParseException("Unterminated array literal");
    }

    private String readString() throws ParseException {
        /*
         * Optimization: if the source contains no escaped characters, create the
         * string directly from the source text.
         */
        int stringStart = pos;
        while (pos < length) {
            char c = src.charAt(pos++);
            if (c <= '\u001F') {
                throw new ParseException("String contains control character");
            } else if (c == '\\') {
                break;
            } else if (c == '"') {
                return src.substring(stringStart, pos - 1);
            }
        }

        /*
         * Slow case: string contains escaped characters.  Copy a maximal sequence
         * of unescaped characters into a temporary buffer, then an escaped
         * character, and repeat until the entire string is consumed.
         */
        StringBuilder b = new StringBuilder();
        while (pos < length) {
            assert src.charAt(pos - 1) == '\\';
            b.append(src, stringStart, pos - 1);
            if (pos >= length) {
                throw new ParseException("Unterminated string");
            }
            char c = src.charAt(pos++);
            switch (c) {
                case '"':
                    b.append('"');
                    break;
                case '\\':
                    b.append('\\');
                    break;
                case '/':
                    b.append('/');
                    break;
                case 'b':
                    b.append('\b');
                    break;
                case 'f':
                    b.append('\f');
                    break;
                case 'n':
                    b.append('\n');
                    break;
                case 'r':
                    b.append('\r');
                    break;
                case 't':
                    b.append('\t');
                    break;
                case 'u':
                    if (length - pos < 5) {
                        throw new ParseException(
                                "Invalid character code: \\u" + src.substring(pos));
                    }
                    int code =
                            fromHex(src.charAt(pos + 0)) << 12
                                    | fromHex(src.charAt(pos + 1)) << 8
                                    | fromHex(src.charAt(pos + 2)) << 4
                                    | fromHex(src.charAt(pos + 3));
                    if (code < 0) {
                        throw new ParseException(
                                "Invalid character code: " + src.substring(pos, pos + 4));
                    }
                    pos += 4;
                    b.append((char) code);
                    break;
                default:
                    throw new ParseException("Unexpected character in string: '\\" + c + "'");
            }
            stringStart = pos;
            while (pos < length) {
                c = src.charAt(pos++);
                if (c <= '\u001F') {
                    throw new ParseException("String contains control character");
                } else if (c == '\\') {
                    break;
                } else if (c == '"') {
                    b.append(src, stringStart, pos - 1);
                    return b.toString();
                }
            }
        }
        throw new ParseException("Unterminated string literal");
    }

    private static int fromHex(char c) {
        return c >= '0' && c <= '9'
                ? c - '0'
                : c >= 'A' && c <= 'F' ? c - 'A' + 10 : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1;
    }

    private Number readNumber(char c) throws ParseException {
        assert c == '-' || (c >= '0' && c <= '9');
        final int numberStart = pos - 1;
        if (c == '-') {
            c = nextOrNumberError(numberStart);
            if (!(c >= '0' && c <= '9')) {
                throw numberError(numberStart, pos);
            }
        }
        if (c != '0') {
            readDigits();
        }
        // read optional fraction part
        if (pos < length) {
            c = src.charAt(pos);
            if (c == '.') {
                pos += 1;
                c = nextOrNumberError(numberStart);
                if (!(c >= '0' && c <= '9')) {
                    throw numberError(numberStart, pos);
                }
                readDigits();
            }
        }
        // read optional exponent part
        if (pos < length) {
            c = src.charAt(pos);
            if (c == 'e' || c == 'E') {
                pos += 1;
                c = nextOrNumberError(numberStart);
                if (c == '-' || c == '+') {
                    c = nextOrNumberError(numberStart);
                }
                if (!(c >= '0' && c <= '9')) {
                    throw numberError(numberStart, pos);
                }
                readDigits();
            }
        }
        String num = src.substring(numberStart, pos);
        final double dval = Double.parseDouble(num);
        final int ival = (int) dval;
        if (ival == dval) {
            return Integer.valueOf(ival);
        }
        return Double.valueOf(dval);
    }

    private ParseException numberError(int start, int end) {
        return new ParseException("Unsupported number format: " + src.substring(start, end));
    }

    private char nextOrNumberError(int numberStart) throws ParseException {
        if (pos >= length) {
            throw numberError(numberStart, length);
        }
        return src.charAt(pos++);
    }

    private void readDigits() {
        for (; pos < length; ++pos) {
            char c = src.charAt(pos);
            if (!(c >= '0' && c <= '9')) {
                break;
            }
        }
    }

    private Boolean readTrue() throws ParseException {
        if (length - pos < 3
                || src.charAt(pos) != 'r'
                || src.charAt(pos + 1) != 'u'
                || src.charAt(pos + 2) != 'e') {
            throw new ParseException("Unexpected token: t");
        }
        pos += 3;
        return Boolean.TRUE;
    }

    private Boolean readFalse() throws ParseException {
        if (length - pos < 4
                || src.charAt(pos) != 'a'
                || src.charAt(pos + 1) != 'l'
                || src.charAt(pos + 2) != 's'
                || src.charAt(pos + 3) != 'e') {
            throw new ParseException("Unexpected token: f");
        }
        pos += 4;
        return Boolean.FALSE;
    }

    private Object readNull() throws ParseException {
        if (length - pos < 3
                || src.charAt(pos) != 'u'
                || src.charAt(pos + 1) != 'l'
                || src.charAt(pos + 2) != 'l') {
            throw new ParseException("Unexpected token: n");
        }
        pos += 3;
        return null;
    }

    private void consumeWhitespace() {
        while (pos < length) {
            char c = src.charAt(pos);
            switch (c) {
                case ' ':
                case '\t':
                case '\r':
                case '\n':
                    pos += 1;
                    break;
                default:
                    return;
            }
        }
    }

    private void consume(char token) throws ParseException {
        consumeWhitespace();
        if (pos >= length) {
            throw new ParseException("Expected " + token + " but reached end of stream");
        }
        char c = src.charAt(pos++);
        if (c == token) {
            return;
        }
        throw new ParseException("Expected " + token + " found " + c);
    }

    public static class ParseException extends Exception {

        private static final long serialVersionUID = 4804542791749920772L;

        ParseException(String message) {
            super(message);
        }

        ParseException(Exception cause) {
            super(cause);
        }
    }
}
