/*
 * Copyright 2010 JBoss, a divison Red Hat, Inc.
 *
 * Licensed 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 org.jboss.errai.marshalling.server;

import org.jboss.errai.marshalling.client.api.json.EJValue;
import org.jboss.errai.marshalling.server.json.impl.ErraiJSONValue;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.nio.CharBuffer;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * High-performance stream JSON parser. Provides the decoding algorithm to interpret the Errai Wire Protcol,
 * including serializable types.  This parser always assumes the outer payload is a Map. So it probably shouldn't
 * be used as a general parser.
 *
 * @author Mike Brock
 * @since 1.1
 */
public class JSONStreamDecoder {
  private final CharBuffer buffer;
  private final BufferedReader reader;

  private char carry;
  private int read;
  private boolean initial = true;

  /**
   * Decodes the JSON payload by reading from the given stream of UTF-8 encoded
   * characters. Reads to the end of the input stream unless there are errors,
   * in which case the current position in the stream will not be at EOF, but
   * may possibly be beyond the character that caused the error.
   *
   * @param inStream
   *          The input stream to read from. It must contain character data
   *          encoded as UTF-8, and it must be positioned to read from the start
   *          of the JSON message to be parsed.
   */
  public JSONStreamDecoder(final InputStream inStream) {
    this.buffer = CharBuffer.allocate(25);
    try {
      this.reader = new BufferedReader(
              new InputStreamReader(inStream, "UTF-8")
      );
    }
    catch (UnsupportedEncodingException e) {
      throw new Error("UTF-8 is not supported by this JVM?", e);
    }
  }

  public static EJValue decode(final InputStream instream) throws IOException {
    return new JSONStreamDecoder(instream).parse();
  }

  public char read() throws IOException {
    if (carry != 0) {
      final char oldCarry = carry;
      carry = 0;
      return oldCarry;
    }
    if (read <= 0) {
      if (!initial) buffer.rewind();
      initial = false;
      if ((read = reader.read(buffer)) <= 0) {
        return 0;
      }
      buffer.rewind();
    }
    read--;
    return buffer.get();
  }

  public EJValue parse() {
    try {
      return new ErraiJSONValue(_parse(new OuterContext()));
    }
    catch (Exception e) {
      throw new RuntimeException(e);
    }
  }

  private Object _parse(Context ctx) throws IOException {
    char c;
    StringBuilder appender;
    while ((c = read()) != 0) {
      switch (c) {
        case '[':
          ctx.addValue(_parse(new ArrayContext(new ArrayList<Object>())));
          break;

        case '{':
          ctx.addValue(_parse(new ObjectContext(new LinkedHashMap<Object, Object>())));
          break;

        case ']':
        case '}':
          return ctx.record();

        case ',':
          ctx.record();
          break;

        case '"':
        case '\'':
          char term = c;
          appender = new StringBuilder(100);
          StrCapture:
          while ((c = read()) != 0) {
            switch (c) {
              case '\\':
                appender.append(handleEscapeSequence());
                break;
              case '"':
              case '\'':
                if (c == term) {
                  ctx.addValue(appender.toString());
                  term = 0;
                  break StrCapture;
                }
              default:
                appender.append(c);
            }
          }

          if (term != 0) {
            throw new RuntimeException("unterminated string literal");
          }
          break;

        case ':':
          continue;

        default:
          if (isNumberStart(c)) {
            carry = c;
            ctx.addValue(parseDouble());
            break;
          }
          else if (Character.isJavaIdentifierPart(c)) {
            appender = new StringBuilder(100).append(c);

            while (((c = read()) != 0) && Character.isJavaIdentifierPart(c)) {
              appender.append(c);
            }

            String s = appender.toString();

            if (s.length() > 5) ctx.addValue(s);
            else if ("null".equals(s)) {
              ctx.addValue(null);
            }
            else if ("true".equals(s)) {
              ctx.addValue(Boolean.TRUE);
            }
            else if ("false".equals(s)) {
              ctx.addValue(Boolean.FALSE);
            }
            else {
              ctx.addValue(s);
            }

            if (c != 0) carry = c;
          }
      }
    }

    return ctx.record();
  }

  private char handleEscapeSequence() throws IOException {
    char c;
    switch (c = read()) {
      case '\\':
        return '\\';
      case '/':
        return '/';
      case 'b':
        return '\b';
      case 'f':
        return '\f';
      case 't':
        return '\t';
      case 'r':
        return '\r';
      case 'n':
        return '\n';
      case '\'':
        return '\'';
      case '"':
        return '\"';
      case 'u':
        //handle unicode
        char[] unicodeSeq = new char[4];
        int i = 0;
        for (; i < 4 && isValidHexPart(c = read()); i++) {
          unicodeSeq[i] = c;
        }
        if (i != 4) {
          throw new RuntimeException("illegal unicode escape sequence: expected 4 hex characters after \\u");
        }

        return (char) Integer.decode("0x" + new String(unicodeSeq)).intValue();

      default:
        throw new RuntimeException("illegal escape sequence: " + c);
    }
  }

  /** The states the double recognizer can go through while attempting to parse a JSON numeric value. */
  private static enum State { READ_SIGN, READ_INT, READ_FRAC, READ_EXP_SIGN, READ_EXP };

  /**
   * Parses a JSON numeric literal <b>with the side effect of consuming
   * characters from the input</b> up until a character is encountered that
   * cannot be used to form a JSON number. JSON numbers have the following
   * grammar:
   *
   * <dl>
   * <dt><i>number</i>
   * <dd><i>int</i>
   * <dd><i>int frac</i>
   * <dd><i>int exp</i>
   * <dd><i>int frac exp</i>
   *
   * <dt><i>int</i>
   * <dd><i>digit</i>
   * <dd><i>digit1-9</i> <i>digits</i>
   * <dd><b>'-'</b> <i>digit</i>
   * <dd><b>'-'</b> <i>digit1-9</i> <i>digits</i>
   *
   * <dt><i>frac</i>
   * <dd><b>'.'</b> <i>digits</i>
   *
   * <dt><i>exp</i>
   * <dd><i>e digits</i>
   *
   * <dt><i>digits</i>
   * <dd><i>digit</i>
   * <dd><i>digit digits</i>
   *
   * <dt><i>digit1-9</i>
   * <dd><b>'1'</b> | <b>'2'</b> | <b>'3'</b> | <b>'4'</b> | <b>'5'</b> |
   * <b>'6'</b> | <b>'7'</b> | <b>'8'</b> | <b>'9'</b>
   *
   * <dt><i>digit</i>
   * <dd><b>'0'</b> | <b>'1'</b> | <b>'2'</b> | <b>'3'</b> | <b>'4'</b> |
   * <b>'5'</b> | <b>'6'</b> | <b>'7'</b> | <b>'8'</b> | <b>'9'</b>
   *
   * <dt><i>e</i>
   * <dd><b>'e'</b> | <b>'e+'</b> | <b>'e-'</b> | <b>'E'</b> | <b>'E+'</b> |
   * <b>'E-'</b>
   * </dl>
   *
   * @return The number that was parsed from the input stream.
   * <p><i>Note on side effects:</i>after this method returns, the next
   * @throws IOException
   */
  private double parseDouble() throws IOException {
    final StringBuilder sb = new StringBuilder(25);

    State state = State.READ_SIGN;

    char c;

    recognize:
    while ((c = read()) != 0) {
      switch (state) {

      case READ_SIGN:
        if (c == '-' || ('0' <= c && c <= '9')) {
          sb.append(c);
          state = State.READ_INT;
        }
        else {
          throw new NumberFormatException("Found '" + c + "' but expected '-' or a digit 1-9");
        }
        break;

      case READ_INT:
        if ('0' <= c && c <= '9') {
          sb.append(c);
        }
        else if (c == '.') {
          sb.append(c);
          state = State.READ_FRAC;
        }
        else if (c == 'E' || c == 'e') {
          sb.append(c);
          state = State.READ_EXP_SIGN;
        }
        else {
          // found the end of the numeric literal
          carry = c;
          break recognize;
        }
        break;

      case READ_FRAC:
        if ('0' <= c && c <= '9') {
          sb.append(c);
        }
        else if (c == 'E' || c == 'e') {
          sb.append(c);
          state = State.READ_EXP_SIGN;
        }
        else {
          // found the end of the numeric literal
          carry = c;
          break recognize;
        }
        break;

      case READ_EXP_SIGN:
        if (c == '-' || c == '+' || ('0' <= c && c <= '9')) {
          sb.append(c);
          state = State.READ_EXP;
        }
        else {
          throw new NumberFormatException("The numeric literal \"" + sb + "\" is malformed (can't end with e or E)");
        }
        break;

      case READ_EXP:
        if ('0' <= c && c <= '9') {
          sb.append(c);
        }
        else {
          // found the end of the numeric literal
          carry = c;
          break recognize;
        }
        break;
      }
    }

    return Double.parseDouble(sb.toString());
  }

  /**
   * Returns true if c could be the start of a JSON number. Note that a return
   * value of true does not indicate that the value will be a valid number. JSON
   * numbers are not permitted to begin with a '0' or a '.', so in those cases
   * {@link #parseDouble()} will throw an error even though this method returned
   * true. This is an acceptable outcome, though, because there is nothing else
   * the errant character could represent in the JSON stream.
   *
   * @param c
   *          the character to test
   * @return true if c is a numeric digit, '-', or '.'.
   */
  private static boolean isNumberStart(char c) {
    switch (c) {
      case '.':
      case '-':
      case '0':
      case '1':
      case '2':
      case '3':
      case '4':
      case '5':
      case '6':
      case '7':
      case '8':
      case '9':
        return true;
      default:
        return false;
    }
  }

  private static boolean isValidHexPart(char c) {
    switch (c) {
      case '.':
      case '-':
      case '0':
      case '1':
      case '2':
      case '3':
      case '4':
      case '5':
      case '6':
      case '7':
      case '8':
      case '9':
      case 'A':
      case 'B':
      case 'C':
      case 'D':
      case 'E':
      case 'F':
        return true;
      default:
        return false;
    }
  }

  private static abstract class Context<T> {
    abstract T record();

    abstract void addValue(Object val);
  }

  private static class OuterContext extends Context<Object> {
    private Context _wrapped;
    private Object col;

    @Override
    Object record() {
      return col;
    }

    @SuppressWarnings("unchecked")
    @Override
    void addValue(Object val) {
      if (_wrapped == null) {
        if (val instanceof List) {
          _wrapped = new ArrayContext((List<Object>) (col = val));
        }
        else if (val instanceof Map) {
          _wrapped = new ObjectContext((Map<Object, Object>) (col = val));
        }
        else if (val instanceof String) {
            _wrapped = new StringContext((String) (col = val));
        }
        else {
          throw new RuntimeException("expected list or map but found: " + (val == null ? null : val.getClass().getName()));
        }
      }
      else {
        _wrapped.addValue(val);
      }
    }
  }

  private static class ArrayContext extends Context<List> {
    List<Object> collection;

    private ArrayContext(List<Object> collection) {
      this.collection = collection;
    }

    @Override
    void addValue(Object val) {
      collection.add(val);
    }

    @Override
    public List record() {
      return collection;
    }
  }

  private static class StringContext extends Context<String> {
    String value;

    private StringContext(String value) {
      this.value = value;
    }

    @Override
    void addValue(Object val) {}

    @Override
    public String record() {
      return value;
    }
  }

  private static class ObjectContext extends Context<Map> {
    protected Object lhs;
    protected Object rhs;

    Map<Object, Object> collection;

    private ObjectContext(Map<Object, Object> collection) {
      this.collection = collection;
    }

    @Override
    void addValue(Object val) {
      if (lhs == null) {
        lhs = val;
      }
      else {
        rhs = val;
      }
    }

    @Override
    Map record() {
      if (lhs != null) {
        collection.put(lhs, rhs);
      }
      lhs = rhs = null;
      return collection;
    }
  }
}