/*
 * Copyright (c) 2015, 2020, Oracle and/or its affiliates.
 *
 * This program is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, version 2.0, as published by the
 * Free Software Foundation.
 *
 * This program is also distributed with certain software (including but not
 * limited to OpenSSL) that is licensed under separate terms, as designated in a
 * particular file or component or in included license documentation. The
 * authors of MySQL hereby grant you an additional permission to link the
 * program and your derivative works with the separately licensed software that
 * they have included with MySQL.
 *
 * Without limiting anything contained in the foregoing, this file, which is
 * part of MySQL Connector/J, is also subject to the Universal FOSS Exception,
 * version 1.0, a copy of which can be found at
 * http://oss.oracle.com/licenses/universal-foss-exception.
 *
 * This program 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.0,
 * for more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
 */

package com.mysql.cj.xdevapi;

import java.io.IOException;
import java.io.StringReader;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Set;

import com.mysql.cj.Messages;
import com.mysql.cj.exceptions.AssertionFailedException;
import com.mysql.cj.exceptions.ExceptionFactory;
import com.mysql.cj.exceptions.WrongArgumentException;

public class JsonParser {

    enum Whitespace {
        TAB('\u0009'), LF('\n'), CR('\r'), SPACE('\u0020');

        public final char CHAR;

        private Whitespace(char character) {
            this.CHAR = character;
        }
    };

    enum StructuralToken {
        /**
         * [ U+005B left square bracket
         */
        LSQBRACKET('\u005B'),
        /**
         * ] U+005D right square bracket
         */
        RSQBRACKET('\u005D'),
        /**
         * { U+007B left curly bracket
         */
        LCRBRACKET('\u007B'),
        /**
         * } U+007D right curly bracket
         */
        RCRBRACKET('\u007D'),
        /**
         * : U+003A colon
         */
        COLON('\u003A'),
        /**
         * , U+002C comma
         */
        COMMA('\u002C');

        public final char CHAR;

        private StructuralToken(char character) {
            this.CHAR = character;
        }

    };

    enum EscapeChar {
        /**
         * \" represents the quotation mark character (U+0022)
         */
        QUOTE('\u0022', "\\\""),
        /**
         * \\ represents the reverse solidus character (U+005C)
         */
        RSOLIDUS('\\', "\\\\"),
        /**
         * \/ represents the solidus character (U+002F)
         */
        SOLIDUS('\u002F', "\\\u002F"),
        /**
         * \b represents the backspace character (U+0008)
         */
        BACKSPACE('\u0008', "\\b"),
        /**
         * \f represents the form feed character (U+000C)
         */
        FF('\u000C', "\\f"),
        /**
         * \n represents the line feed character (U+000A)
         */
        LF('\n', "\\n"),
        /**
         * \r represents the carriage return character (U+000D)
         */
        CR('\r', "\\r"),
        /**
         * \t represents the character tabulation character (U+0009)
         */
        TAB('\t', "\\t");

        public final char CHAR;
        public final String ESCAPED;

        private EscapeChar(char character, String escaped) {
            this.CHAR = character;
            this.ESCAPED = escaped;
        }
    };

    static Set<Character> whitespaceChars = new HashSet<>();
    static HashMap<Character, Character> unescapeChars = new HashMap<>();

    static {
        for (EscapeChar ec : EscapeChar.values()) {
            unescapeChars.put(ec.ESCAPED.charAt(1), ec.CHAR);
        }
        for (Whitespace ws : Whitespace.values()) {
            whitespaceChars.add(ws.CHAR);
        }
    }

    private static boolean isValidEndOfValue(char ch) {
        return StructuralToken.COMMA.CHAR == ch || StructuralToken.RCRBRACKET.CHAR == ch || StructuralToken.RSQBRACKET.CHAR == ch;
    }

    /**
     * Create {@link DbDoc} object from JSON string.
     * 
     * @param jsonString
     *            JSON string representing a document
     * @return New {@link DbDoc} object initialized by parsed JSON string.
     */
    public static DbDoc parseDoc(String jsonString) {
        try {
            return JsonParser.parseDoc(new StringReader(jsonString));
        } catch (IOException ex) {
            throw AssertionFailedException.shouldNotHappen(ex);
        }
    }

    /**
     * Create {@link DbDoc} object from JSON string provided by reader.
     * 
     * @param reader
     *            JSON string reader.
     * @return
     *         New {@link DbDoc} object initialized by parsed JSON string.
     * @throws IOException
     *             if can't read
     */
    public static DbDoc parseDoc(StringReader reader) throws IOException {

        DbDoc doc = new DbDocImpl();
        JsonValue val;

        int leftBrackets = 0;
        int rightBrackets = 0;

        int intch;
        while ((intch = reader.read()) != -1) {
            String key = null;
            char ch = (char) intch;
            if (ch == StructuralToken.LCRBRACKET.CHAR || ch == StructuralToken.COMMA.CHAR) {
                if (ch == StructuralToken.LCRBRACKET.CHAR) {
                    leftBrackets++;
                }
                if ((key = nextKey(reader)) != null) {
                    try {
                        if ((val = nextValue(reader)) != null) {
                            doc.put(key, val);
                        } else {
                            reader.reset();
                        }
                    } catch (WrongArgumentException ex) {
                        throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.0", new String[] { key }), ex);
                    }
                } else {
                    reader.reset();
                }
            } else if (ch == StructuralToken.RCRBRACKET.CHAR) {
                rightBrackets++;
                break;
            } else {
                if (!whitespaceChars.contains(ch)) {
                    throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.1", new Character[] { ch }));
                }
            }
        }

        if (leftBrackets == 0) {
            throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.2"));
        } else if (leftBrackets > rightBrackets) {
            throw ExceptionFactory.createException(WrongArgumentException.class,
                    Messages.getString("JsonParser.3", new Character[] { StructuralToken.RCRBRACKET.CHAR }));
        }

        return doc;
    }

    /**
     * Create {@link JsonArray} object from JSON string provided by reader.
     * 
     * @param reader
     *            JSON string reader.
     * @return
     *         New {@link JsonArray} object initialized by parsed JSON string.
     * @throws IOException
     *             if can't read
     */
    public static JsonArray parseArray(StringReader reader) throws IOException {

        JsonArray arr = new JsonArray();
        JsonValue val;
        int openings = 0;

        int intch;
        while ((intch = reader.read()) != -1) {
            char ch = (char) intch;
            if (ch == StructuralToken.LSQBRACKET.CHAR || ch == StructuralToken.COMMA.CHAR) {
                if (ch == StructuralToken.LSQBRACKET.CHAR) {
                    openings++;
                }
                if ((val = nextValue(reader)) != null) {
                    arr.add(val);
                } else {
                    reader.reset();
                }

            } else if (ch == StructuralToken.RSQBRACKET.CHAR) {
                openings--;
                break;

            } else if (!whitespaceChars.contains(ch)) {
                throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.1", new Character[] { ch }));

            }
        }

        if (openings > 0) {
            throw ExceptionFactory.createException(WrongArgumentException.class,
                    Messages.getString("JsonParser.3", new Character[] { StructuralToken.RSQBRACKET.CHAR }));
        }

        return arr;
    }

    private static String nextKey(StringReader reader) throws IOException {
        reader.mark(1);

        JsonString val = parseString(reader);
        if (val == null) {
            reader.reset();
        }

        // find delimiter
        int intch;
        char ch = ' ';
        while ((intch = reader.read()) != -1) {
            ch = (char) intch;
            if (ch == StructuralToken.COLON.CHAR) {
                // key/value delimiter found
                break;
            } else if (ch == StructuralToken.RCRBRACKET.CHAR) {
                // end of document
                break;
            } else if (!whitespaceChars.contains(ch)) {
                throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.1", new Character[] { ch }));
            }
        }

        if (ch != StructuralToken.COLON.CHAR && val != null && val.getString().length() > 0) {
            throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.4", new String[] { val.getString() }));
        }
        return val != null ? val.getString() : null;
    }

    private static JsonValue nextValue(StringReader reader) throws IOException {
        reader.mark(1);
        int intch;
        while ((intch = reader.read()) != -1) {
            char ch = (char) intch;
            if (ch == EscapeChar.QUOTE.CHAR) {
                // String detected
                reader.reset();
                return parseString(reader);

            } else if (ch == StructuralToken.LSQBRACKET.CHAR) {
                // array detected
                reader.reset();
                return parseArray(reader);

            } else if (ch == StructuralToken.LCRBRACKET.CHAR) {
                // inner Object detected
                reader.reset();
                return parseDoc(reader);

            } else if (ch == '\u002D' || (ch >= '\u0030' && ch <= '\u0039')) { // {-,0-9}
                // Number detected
                reader.reset();
                return parseNumber(reader);

            } else if (ch == JsonLiteral.TRUE.value.charAt(0)) {
                // "true" literal detected
                reader.reset();
                return parseLiteral(reader);

            } else if (ch == JsonLiteral.FALSE.value.charAt(0)) {
                // "false" literal detected
                reader.reset();
                return parseLiteral(reader);

            } else if (ch == JsonLiteral.NULL.value.charAt(0)) {
                // "null" literal detected
                reader.reset();
                return parseLiteral(reader);

            } else if (ch == StructuralToken.RSQBRACKET.CHAR) {
                // empty array
                return null;

            } else if (!whitespaceChars.contains(ch)) {
                throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.1", new Character[] { ch }));
            }
            reader.mark(1);
        }

        throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.5"));
    }

    private static void appendChar(StringBuilder sb, char ch) {
        if (sb == null) {
            if (!whitespaceChars.contains(ch)) {
                throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.6", new Character[] { ch }));
            }
        } else {
            sb.append(ch);
        }
    }

    /**
     * Create {@link JsonString} object from JSON string provided by reader.
     * 
     * @param reader
     *            JSON string reader.
     * @return
     *         New {@link JsonString} object initialized by parsed JSON string or <code>null</code> if no JSON string was found.
     * @throws IOException
     *             if can't read
     */
    static JsonString parseString(StringReader reader) throws IOException {
        int quotes = 0;
        boolean escapeNextChar = false;

        StringBuilder sb = null; // stays null until starting quotation mark is found

        int intch;
        while ((intch = reader.read()) != -1) {
            char ch = (char) intch;
            if (escapeNextChar) {
                if (unescapeChars.containsKey(ch)) {
                    appendChar(sb, unescapeChars.get(ch));
                } else {
                    throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.7", new Character[] { ch }));
                }
                escapeNextChar = false;

            } else if (ch == EscapeChar.QUOTE.CHAR) {
                if (sb == null) {
                    // start of string detected
                    sb = new StringBuilder();
                    quotes++;
                } else {
                    // end of string detected
                    quotes--;
                    break;
                }

            } else if (quotes == 0 && ch == StructuralToken.RCRBRACKET.CHAR) {
                // end of document
                break;

            } else if (ch == EscapeChar.RSOLIDUS.CHAR) {
                escapeNextChar = true;

            } else {
                appendChar(sb, ch);
            }
        }

        if (quotes > 0) {
            throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.3", new Character[] { EscapeChar.QUOTE.CHAR }));
        }

        return sb == null ? null : new JsonString().setValue(sb.toString());
    }

    /**
     * Create {@link JsonNumber} object from JSON string provided by reader.
     * 
     * @param reader
     *            JSON string reader.
     * @return
     *         New {@link JsonNumber} object initialized by parsed JSON string.
     * @throws IOException
     *             if can't read
     */
    static JsonNumber parseNumber(StringReader reader) throws IOException {

        StringBuilder sb = null;
        char lastChar = ' ';
        boolean hasFractionalPart = false;
        boolean hasExponent = false;

        int intch;
        while ((intch = reader.read()) != -1) {
            char ch = (char) intch;

            if (sb == null) {
                // number is still not found
                if (ch == '\u002D') {
                    // first char of number is minus
                    sb = new StringBuilder();
                    sb.append(ch);
                } else if (ch >= '\u0030' && ch <= '\u0039') {
                    // first char of number is digit
                    sb = new StringBuilder();
                    sb.append(ch);
                } else if (!whitespaceChars.contains(ch)) {
                    // only white spaces are allowed before value
                    throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.1", new Character[] { ch }));
                }
            } else if (ch == '\u002D') {
                // '-' is allowed only on first position and after exponent character
                if (lastChar == 'E' || lastChar == 'e') {
                    sb.append(ch);
                } else {
                    throw ExceptionFactory.createException(WrongArgumentException.class,
                            Messages.getString("JsonParser.8", new Object[] { ch, sb.toString() }));
                }

            } else if (ch >= '\u0030' && ch <= '\u0039') { // 0-9
                sb.append(ch);

            } else if (ch == 'E' || ch == 'e') {
                // exponent character is allowed only after a digit
                if (lastChar >= '\u0030' && lastChar <= '\u0039') {
                    hasExponent = true;
                    sb.append(ch);
                } else {
                    throw ExceptionFactory.createException(WrongArgumentException.class,
                            Messages.getString("JsonParser.8", new Object[] { ch, sb.toString() }));
                }

            } else if (ch == '\u002E') {
                // '.' is allowed only once, after a digit and not in exponent part
                if (hasFractionalPart) {
                    throw ExceptionFactory.createException(WrongArgumentException.class,
                            Messages.getString("JsonParser.10", new Object[] { ch, sb.toString() }));
                }
                if (hasExponent) {
                    throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.11"));
                }
                if (lastChar >= '\u0030' && lastChar <= '\u0039') {
                    hasFractionalPart = true;
                    sb.append(ch);
                } else {
                    throw ExceptionFactory.createException(WrongArgumentException.class,
                            Messages.getString("JsonParser.8", new Object[] { ch, sb.toString() }));
                }

            } else if (ch == '\u002B') {
                // '+' is allowed only once after exponent character
                if (lastChar == 'E' || lastChar == 'e') {
                    sb.append(ch);
                } else {
                    throw ExceptionFactory.createException(WrongArgumentException.class,
                            Messages.getString("JsonParser.8", new Object[] { ch, sb.toString() }));
                }

            } else if (whitespaceChars.contains(ch) || isValidEndOfValue(ch)) {
                // any whitespace, comma or right bracket character means the end of Number in case we already placed something to buffer
                reader.reset(); // set reader position to last number char
                break;

            } else {
                // no other characters are allowed after value
                throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.1", new Character[] { ch }));
            }
            lastChar = ch;
            // it's safe to mark() here because the "higher" level marks won't be reset() once we start reading a number
            reader.mark(1);
        }

        if (sb == null || sb.length() == 0) {
            throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.5"));
        }

        return new JsonNumber().setValue(sb.toString());
    }

    /**
     * Create {@link JsonLiteral} object from JSON string provided by reader.
     * 
     * @param reader
     *            JSON string reader.
     * @return
     *         New {@link JsonLiteral} object initialized by parsed JSON string.
     * @throws IOException
     *             if can't read
     */
    static JsonLiteral parseLiteral(StringReader reader) throws IOException {
        StringBuilder sb = null;
        JsonLiteral res = null;
        int literalIndex = 0;

        int intch;
        while ((intch = reader.read()) != -1) {
            char ch = (char) intch;
            if (sb == null) {
                // literal is still not found
                if (ch == JsonLiteral.TRUE.value.charAt(0)) {
                    // first char of "true" literal is found
                    res = JsonLiteral.TRUE;
                    sb = new StringBuilder();
                    sb.append(ch);
                    literalIndex++;
                } else if (ch == JsonLiteral.FALSE.value.charAt(0)) {
                    // first char of "false" literal is found
                    res = JsonLiteral.FALSE;
                    sb = new StringBuilder();
                    sb.append(ch);
                    literalIndex++;
                } else if (ch == JsonLiteral.NULL.value.charAt(0)) {
                    // first char of "null" literal is found
                    res = JsonLiteral.NULL;
                    sb = new StringBuilder();
                    sb.append(ch);
                    literalIndex++;
                } else if (!whitespaceChars.contains(ch)) {
                    // only whitespace chars are allowed before value
                    throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.1", new Character[] { ch }));
                }
            } else if (literalIndex < res.value.length() && ch == res.value.charAt(literalIndex)) {
                sb.append(ch);
                literalIndex++;

            } else if (whitespaceChars.contains(ch) || isValidEndOfValue(ch)) {
                // any whitespace, colon or right bracket character means the end of literal in case we already placed something to buffer
                reader.reset(); // set reader position to last number char
                break;

            } else {
                // no other characters are allowed after value
                throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.1", new Character[] { ch }));
            }
            // it's safe to mark() here because the "higher" level marks won't be reset() once we start reading a number
            reader.mark(1);
        }

        if (sb == null) {
            throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.5"));
        }

        if (literalIndex == res.value.length()) {
            return res;
        }

        throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("JsonParser.12", new String[] { sb.toString() }));
    }

}
