/*
 * 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 freemarker.core;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import freemarker.template.SimpleHash;
import freemarker.template.SimpleNumber;
import freemarker.template.SimpleScalar;
import freemarker.template.SimpleSequence;
import freemarker.template.Template;
import freemarker.template.TemplateBooleanModel;
import freemarker.template.TemplateHashModelEx2;
import freemarker.template.TemplateModel;
import freemarker.template.TemplateModelException;
import freemarker.template.TemplateNumberModel;
import freemarker.template.TemplateScalarModel;
import freemarker.template.TemplateSequenceModel;
import freemarker.template._TemplateAPI;
import freemarker.template.utility.Constants;
import freemarker.template.utility.NumberUtil;
import freemarker.template.utility.StringUtil;

/**
 * JSON parser that returns a {@link TameplatModel}, similar to what FTL literals product (and so, what
 * @code ?eval} would return). A notable difference compared to the result FTL literals is that this doesn't use the
 * {@link ParserConfiguration#getArithmeticEngine()} to parse numbers, as JSON has its own fixed number syntax. For
 * numbers this parser returns {@link SimpleNumberModel}-s, where the wrapped numbers will be {@link Integer}-s when
 * they fit into that, otherwise they will be {@link Long}-s if they fit into that, otherwise they will be
 * {@link BigDecimal}-s. Another difference to the result of FTL literals is that instead of
 * {@code HashLiteral.SequenceHash} it uses {@link SimpleHash} with {@link LinkedHashMap} as backing store, for
 * efficiency.
 *
 * <p>This parser allows certain things that are errors in pure JSON:
 * <ul>
 *     <li>JavaScript comments are supported</li>
 *     <li>Non-breaking space (nbsp) and BOM are treated as whitespace</li>
 * </ul>
 */
class JSONParser {

    private static final String UNCLOSED_OBJECT_MESSAGE
            = "This {...} was still unclosed when the end of the file was reached. (Look for a missing \"}\")";

    private static final String UNCLOSED_ARRAY_MESSAGE
            = "This [...] was still unclosed when the end of the file was reached. (Look for a missing \"]\")";

    private static final BigDecimal MIN_INT_AS_BIGDECIMAL = BigDecimal.valueOf(Integer.MIN_VALUE);
    private static final BigDecimal MAX_INT_AS_BIGDECIMAL = BigDecimal.valueOf(Integer.MAX_VALUE);
    private static final BigDecimal MIN_LONG_AS_BIGDECIMAL = BigDecimal.valueOf(Long.MIN_VALUE);
    private static final BigDecimal MAX_LONG_AS_BIGDECIMAL = BigDecimal.valueOf(Long.MAX_VALUE);

    private final String src;
    private final int ln;

    private int p;

    public static TemplateModel parse(String src) throws JSONParseException {
        return new JSONParser(src).parse();
    }

    /**
     * @param sourceLocation Only used in error messages, maybe {@code null}.
     */
    private JSONParser(String src) {
        this.src = src;
        this.ln = src.length();
    }

    private TemplateModel parse() throws JSONParseException {
        skipWS();
        TemplateModel result = consumeValue("Empty JSON (contains no value)", p);

        skipWS();
        if (p != ln) {
            throw newParseException("End-of-file was expected but found further non-whitespace characters.");
        }

        return result;
    }

    private TemplateModel consumeValue(String eofErrorMessage, int eofBlamePosition) throws JSONParseException {
        if (p == ln) {
            throw newParseException(
                    eofErrorMessage == null
                            ? "A value was expected here, but end-of-file was reached." : eofErrorMessage,
                    eofBlamePosition == -1 ? p : eofBlamePosition);
        }

        TemplateModel result;

        result = tryConsumeString();
        if (result != null) return result;

        result = tryConsumeNumber();
        if (result != null) return result;

        result = tryConsumeObject();
        if (result != null) return result;

        result = tryConsumeArray();
        if (result != null) return result;

        result = tryConsumeTrueFalseNull();
        if (result != null) return result != TemplateNullModel.INSTANCE ? result : null;

        // Better error message for a frequent mistake:
        if (p < ln && src.charAt(p) == '\'') {
            throw newParseException("Unexpected apostrophe-quote character. "
                    + "JSON strings must be quoted with quotation mark.");
        }

        throw newParseException(
                "Expected either the beginning of a (negative) number or the beginning of one of these: "
                        + "{...}, [...], \"...\", true, false, null. Found character " + StringUtil.jQuote(src.charAt(p))
                        + " instead.");
    }

    private TemplateModel tryConsumeTrueFalseNull() throws JSONParseException {
        int startP = p;
        if (p < ln && isIdentifierStart(src.charAt(p))) {
            p++;
            while (p < ln && isIdentifierPart(src.charAt(p))) {
                p++;
            }
        }

        if (startP == p) return null;

        String keyword = src.substring(startP, p);
        if (keyword.equals("true")) {
            return TemplateBooleanModel.TRUE;
        } else if (keyword.equals("false")) {
            return TemplateBooleanModel.FALSE;
        } else if (keyword.equals("null")) {
            return TemplateNullModel.INSTANCE;
        }

        throw newParseException(
                "Invalid JSON keyword: " + StringUtil.jQuote(keyword)
                        + ". Should be one of: true, false, null. "
                        + "If it meant to be a string then it must be quoted.", startP);
    }

    private TemplateNumberModel tryConsumeNumber() throws JSONParseException {
        if (p >= ln) {
            return null;
        }
        char c = src.charAt(p);
        boolean negative = c == '-';
        if (!(negative || isDigit(c) || c == '.')) {
            return null;
        }

        int startP = p;

        if (negative) {
            if (p + 1 >= ln) {
                throw newParseException("Expected a digit after \"-\", but reached end-of-file.");
            }
            char lookAheadC = src.charAt(p + 1);
            if (!(isDigit(lookAheadC) || lookAheadC == '.')) {
                return null;
            }
            p++; // Consume "-" only, not the digit
        }

        long longSum = 0;
        boolean firstDigit = true;
        consumeLongFittingHead: do {
            c = src.charAt(p);

            if (!isDigit(c)) {
                if (c == '.' && firstDigit) {
                    throw newParseException("JSON doesn't allow numbers starting with \".\".");
                }
                break consumeLongFittingHead;
            }

            int digit = c - '0';
            if (longSum == 0) {
                if (!firstDigit) {
                    throw newParseException("JSON doesn't allow superfluous leading 0-s.", p - 1);
                }

                longSum = !negative ? digit : -digit;
                p++;
            } else {
                long prevLongSum = longSum;
                longSum = longSum * 10 + (!negative ? digit : -digit);
                if (!negative && prevLongSum > longSum || negative && prevLongSum < longSum) {
                    // We had an overflow => Can't consume this digit as long-fitting
                    break consumeLongFittingHead;
                }
                p++;
            }
            firstDigit = false;
        } while (p < ln);

        if (p < ln && isBigDecimalFittingTailCharacter(c)) {
            char lastC = c;
            p++;

            consumeBigDecimalFittingTail: while (p < ln) {
                c = src.charAt(p);
                if (isBigDecimalFittingTailCharacter(c)) {
                    p++;
                } else if ((c == '+' || c == '-') && isE(lastC)) {
                    p++;
                } else {
                    break consumeBigDecimalFittingTail;
                }
                lastC = c;
            }

            String numStr = src.substring(startP, p);
            BigDecimal bd;
            try {
                bd = new BigDecimal(numStr);
            } catch (NumberFormatException e) {
                throw new JSONParseException("Malformed number: " + numStr, src, startP, e);
            }

            if (bd.compareTo(MIN_INT_AS_BIGDECIMAL) >= 0 && bd.compareTo(MAX_INT_AS_BIGDECIMAL) <= 0) {
                if (NumberUtil.isIntegerBigDecimal(bd)) {
                    return new SimpleNumber(bd.intValue());
                }
            } else if (bd.compareTo(MIN_LONG_AS_BIGDECIMAL) >= 0 && bd.compareTo(MAX_LONG_AS_BIGDECIMAL) <= 0) {
                if (NumberUtil.isIntegerBigDecimal(bd)) {
                    return new SimpleNumber(bd.longValue());
                }
            }
            return new SimpleNumber(bd);
        } else {
            return new SimpleNumber(
                    longSum <= Integer.MAX_VALUE && longSum >= Integer.MIN_VALUE
                            ? (Number) (int) longSum
                            : longSum);
        }
    }

    private TemplateScalarModel tryConsumeString() throws JSONParseException {
        int startP = p;
        if (!tryConsumeChar('"')) return null;

        StringBuilder sb = new StringBuilder();
        char c = 0;
        while (p < ln) {
            c = src.charAt(p);

            if (c == '"') {
                p++;
                return new SimpleScalar(sb.toString());  // Call normally returns here!
            } else if (c == '\\') {
                p++;
                sb.append(consumeAfterBackslash());
            } else if (c <= 0x1F) {
                throw newParseException("JSON doesn't allow unescaped control characters in string literals, "
                        + "but found character with code (decimal): " + (int) c);
            } else {
                p++;
                sb.append(c);
            }
        }

        throw newParseException("String literal was still unclosed when the end of the file was reached. "
                + "(Look for missing or accidentally escaped closing quotation mark.)", startP);
    }

    private TemplateSequenceModel tryConsumeArray() throws JSONParseException {
        int startP = p;
        if (!tryConsumeChar('[')) return null;

        skipWS();
        if (tryConsumeChar(']')) return Constants.EMPTY_SEQUENCE;

        boolean afterComma = false;
        SimpleSequence elements = new SimpleSequence(_TemplateAPI.SAFE_OBJECT_WRAPPER);
        do {
            skipWS();
            elements.add(consumeValue(afterComma ? null : UNCLOSED_ARRAY_MESSAGE, afterComma ? -1 : startP));

            skipWS();
            afterComma = true;
        } while (consumeChar(',', ']', UNCLOSED_ARRAY_MESSAGE, startP) == ',');
        return elements;
    }

    private TemplateHashModelEx2 tryConsumeObject() throws JSONParseException {
        int startP = p;
        if (!tryConsumeChar('{')) return null;

        skipWS();
        if (tryConsumeChar('}')) return Constants.EMPTY_HASH_EX2;

        boolean afterComma = false;
        Map<String, Object> map = new LinkedHashMap<>();  // Must keeps original order!
        do {
            skipWS();
            int keyStartP = p;
            Object key = consumeValue(afterComma ? null : UNCLOSED_OBJECT_MESSAGE, afterComma ? -1 : startP);
            if (!(key instanceof TemplateScalarModel)) {
                throw newParseException("Wrong key type. JSON only allows string keys inside {...}.", keyStartP);
            }
            String strKey = null;
            try {
                strKey = ((TemplateScalarModel) key).getAsString();
            } catch (TemplateModelException e) {
                throw new BugException(e);
            }

            skipWS();
            consumeChar(':');

            skipWS();
            map.put(strKey, consumeValue(null, -1));

            skipWS();
            afterComma = true;
        } while (consumeChar(',', '}', UNCLOSED_OBJECT_MESSAGE, startP) == ',');
        return new SimpleHash(map, _TemplateAPI.SAFE_OBJECT_WRAPPER, 0);
    }

    private boolean isE(char c) {
        return c == 'e' || c == 'E';
    }

    private boolean isBigDecimalFittingTailCharacter(char c) {
        return c == '.' || isE(c) || isDigit(c);
    }

    private char consumeAfterBackslash() throws JSONParseException {
        if (p == ln) {
            throw newParseException("Reached the end of the file, but the escape is unclosed.");
        }

        final char c = src.charAt(p);
        switch (c) {
            case '"':
            case '\\':
            case '/':
                p++;
                return c;
            case 'b':
                p++;
                return '\b';
            case 'f':
                p++;
                return '\f';
            case 'n':
                p++;
                return '\n';
            case 'r':
                p++;
                return '\r';
            case 't':
                p++;
                return '\t';
            case 'u':
                p++;
                return consumeAfterBackslashU();
        }
        throw newParseException("Unsupported escape: \\" + c);
    }

    private char consumeAfterBackslashU() throws JSONParseException {
        if (p + 3 >= ln) {
            throw newParseException("\\u must be followed by exactly 4 hexadecimal digits");
        }
        final String hex = src.substring(p, p + 4);
        try {
            char r = (char) Integer.parseInt(hex, 16);
            p += 4;
            return r;
        } catch (NumberFormatException e) {
            throw newParseException("\\u must be followed by exactly 4 hexadecimal digits, but was followed by "
                    + StringUtil.jQuote(hex) + ".");
        }
    }

    private boolean tryConsumeChar(char c) {
        if (p < ln && src.charAt(p) == c) {
            p++;
            return true;
        } else {
            return false;
        }
    }

    private void consumeChar(char expected) throws JSONParseException {
        consumeChar(expected, (char) 0, null, -1);
    }

    private char consumeChar(char expected1, char expected2, String eofErrorHint, int eofErrorP) throws JSONParseException {
        if (p >= ln) {
            throw newParseException(eofErrorHint == null
                            ? "Expected " + StringUtil.jQuote(expected1)
                            + ( expected2 != 0 ? " or " + StringUtil.jQuote(expected2) : "")
                            + " character, but reached end-of-file. "
                            : eofErrorHint,
                    eofErrorP == -1 ? p : eofErrorP);
        }
        char c = src.charAt(p);
        if (c == expected1 || (expected2 != 0 && c == expected2)) {
            p++;
            return c;
        }
        throw newParseException("Expected " + StringUtil.jQuote(expected1)
                + ( expected2 != 0 ? " or " + StringUtil.jQuote(expected2) : "")
                + " character, but found " + StringUtil.jQuote(c) + " instead.");
    }

    private void skipWS() throws JSONParseException {
        do {
            while (p < ln && isWS(src.charAt(p))) {
                p++;
            }
        } while (skipComment());
    }

    private boolean skipComment() throws JSONParseException {
        if (p + 1 < ln) {
            if (src.charAt(p) == '/') {
                char c2 = src.charAt(p + 1);
                if (c2 == '/') {
                    int eolP = p + 2;
                    while (eolP < ln && !isLineBreak(src.charAt(eolP))) {
                        eolP++;
                    }
                    p = eolP;
                    return true;
                } else if (c2 == '*') {
                    int closerP = p + 3;
                    while (closerP < ln && !(src.charAt(closerP - 1) == '*' && src.charAt(closerP) == '/')) {
                        closerP++;
                    }
                    if (closerP >= ln) {
                        throw newParseException("Unclosed comment");
                    }
                    p = closerP + 1;
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Whitespace as specified by JSON, plus non-breaking space (nbsp), and BOM.
     */
    private static boolean isWS(char c) {
        return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == 0xA0 || c == '\uFEFF';
    }

    private static boolean isLineBreak(char c) {
        return c == '\r' || c == '\n';
    }

    private static boolean isIdentifierStart(char c) {
        return Character.isLetter(c) || c == '_' || c == '$';
    }

    private static boolean isDigit(char c) {
        return c >= '0' && c <= '9';
    }

    private static boolean isIdentifierPart(char c) {
        return isIdentifierStart(c) || isDigit(c);
    }

    private JSONParseException newParseException(String message) {
        return newParseException(message, p);
    }

    private JSONParseException newParseException(String message, int p) {
        return new JSONParseException(message, src, p);
    }

    static class JSONParseException extends Exception {
        public JSONParseException(String message, String src, int position) {
            super(createSourceCodeErrorMessage(message, src, position));
        }

        public JSONParseException(String message, String src, int position,
                Throwable cause) {
            super(createSourceCodeErrorMessage(message, src, position), cause);
        }

    }

    private static int MAX_QUOTATION_LENGTH = 50;

    private static String createSourceCodeErrorMessage(String message, String srcCode, int position) {
        int ln = srcCode.length();
        if (position < 0) {
            position = 0;
        }
        if (position >= ln) {
            return message + "\n"
                    + "Error location: At the end of text.";
        }

        int i;
        char c;
        int rowBegin = 0;
        int rowEnd;
        int row = 1;
        char lastChar = 0;
        for (i = 0; i <= position; i++) {
            c = srcCode.charAt(i);
            if (lastChar == 0xA) {
                rowBegin = i;
                row++;
            } else if (lastChar == 0xD && c != 0xA) {
                rowBegin = i;
                row++;
            }
            lastChar = c;
        }
        for (i = position; i < ln; i++) {
            c = srcCode.charAt(i);
            if (c == 0xA || c == 0xD) {
                if (c == 0xA && i > 0 && srcCode.charAt(i - 1) == 0xD) {
                    i--;
                }
                break;
            }
        }
        rowEnd = i - 1;
        if (position > rowEnd + 1) {
            position = rowEnd + 1;
        }
        int col = position - rowBegin + 1;
        if (rowBegin > rowEnd) {
            return message + "\n"
                    + "Error location: line "
                    + row + ", column " + col + ":\n"
                    + "(Can't show the line because it is empty.)";
        }
        String s1 = srcCode.substring(rowBegin, position);
        String s2 = srcCode.substring(position, rowEnd + 1);
        s1 = expandTabs(s1, 8);
        int ln1 = s1.length();
        s2 = expandTabs(s2, 8, ln1);
        int ln2 = s2.length();
        if (ln1 + ln2 > MAX_QUOTATION_LENGTH) {
            int newLn2 = ln2 - ((ln1 + ln2) - MAX_QUOTATION_LENGTH);
            if (newLn2 < 6) {
                newLn2 = 6;
            }
            if (newLn2 < ln2) {
                s2 = s2.substring(0, newLn2 - 3) + "...";
                ln2 = newLn2;
            }
            if (ln1 + ln2 > MAX_QUOTATION_LENGTH) {
                s1 = "..." + s1.substring((ln1 + ln2) - MAX_QUOTATION_LENGTH + 3);
            }
        }
        StringBuilder res = new StringBuilder(message.length() + 80);
        res.append(message);
        res.append("\nError location: line ").append(row).append(", column ").append(col).append(":\n");
        res.append(s1).append(s2).append("\n");
        int x = s1.length();
        while (x != 0) {
            res.append(' ');
            x--;
        }
        res.append('^');

        return res.toString();
    }

    private static String expandTabs(String s, int tabWidth) {
        return expandTabs(s, tabWidth, 0);
    }

    /**
     * Replaces all tab-s with spaces in a single line.
     */
    private static String expandTabs(String s, int tabWidth, int startCol) {
        int e = s.indexOf('\t');
        if (e == -1) {
            return s;
        }
        int b = 0;
        StringBuilder buf = new StringBuilder(s.length() + Math.max(16, tabWidth * 2));
        do {
            buf.append(s, b, e);
            int col = buf.length() + startCol;
            for (int i = tabWidth * (1 + col / tabWidth) - col; i > 0; i--) {
                buf.append(' ');
            }
            b = e + 1;
            e = s.indexOf('\t', b);
        } while (e != -1);
        buf.append(s, b, s.length());
        return buf.toString();
    }

}
