// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC 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 org.openqa.selenium.json;

import java.io.Closeable;
import java.io.IOException;
import java.io.Reader;
import java.io.UncheckedIOException;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Deque;
import java.util.function.Function;
import org.openqa.selenium.internal.Require;

public class JsonInput implements Closeable {

  private final Reader source;
  private volatile boolean readPerformed = false;
  private JsonTypeCoercer coercer;
  private PropertySetting setter;
  private Input input;
  // Used when reading maps and collections so that we handle de-nesting and
  // figuring out whether we're expecting a NAME properly.
  private Deque<Container> stack = new ArrayDeque<>();

  JsonInput(Reader source, JsonTypeCoercer coercer, PropertySetting setter) {
    this.source = Require.nonNull("Source", source);
    this.coercer = Require.nonNull("Coercer", coercer);
    this.input = new Input(source);
    this.setter = Require.nonNull("Setter", setter);
  }

  /**
   * Change how property setting is done. It's polite to set the value back once done processing.
   *
   * @param setter The new {@link PropertySetting} to use.
   * @return The previous {@link PropertySetting} that has just been replaced.
   */
  public PropertySetting propertySetting(PropertySetting setter) {
    PropertySetting previous = this.setter;
    this.setter = Require.nonNull("Setter", setter);
    return previous;
  }

  public JsonInput addCoercers(TypeCoercer<?>... coercers) {
    return addCoercers(Arrays.asList(coercers));
  }

  public JsonInput addCoercers(Iterable<TypeCoercer<?>> coercers) {
    synchronized (this) {
      if (readPerformed) {
        throw new JsonException("JsonInput has already been used and may not be modified");
      }

      this.coercer = new JsonTypeCoercer(coercer, coercers);
    }

    return this;
  }

  @Override
  public void close() {
    try {
      source.close();
    } catch (IOException e) {
      throw new UncheckedIOException(e);
    }
  }

  public JsonType peek() {
    skipWhitespace(input);

    switch (input.peek()) {
      case 'f':
      case 't':
        return JsonType.BOOLEAN;

      case 'n':
        return JsonType.NULL;

      case '-':
      case '+':
      case '0':
      case '1':
      case '2':
      case '3':
      case '4':
      case '5':
      case '6':
      case '7':
      case '8':
      case '9':
        return JsonType.NUMBER;

      case '"':
        return isReadingName() ? JsonType.NAME : JsonType.STRING;

      case '{':
        return JsonType.START_MAP;

      case '}':
        return JsonType.END_MAP;

      case '[':
        return JsonType.START_COLLECTION;

      case ']':
        return JsonType.END_COLLECTION;

      case Input.EOF:
        return JsonType.END;

      default:
        char c = input.read();
        throw new JsonException("Unable to determine type from: " + c + ". " + input);
    }
  }

  public boolean nextBoolean() {
    expect(JsonType.BOOLEAN);
    return read(input.peek() == 't' ? "true" : "false", Boolean::valueOf);
  }

  public String nextName() {
    expect(JsonType.NAME);

    String name = readString();
    skipWhitespace(input);
    char read = input.read();
    if (read != ':') {
      throw new JsonException(
          "Unable to read name. Expected colon separator, but saw '" + read + "'");
    }
    return name;
  }

  public Object nextNull() {
    expect(JsonType.NULL);
    return read("null", str -> null);
  }

  public Number nextNumber() {
    expect(JsonType.NUMBER);
    StringBuilder builder = new StringBuilder();
    // We know it's safe to use a do/while loop since the first character was a number
    boolean fractionalPart = false;
    do {
      char read = input.peek();
      if (Character.isDigit(read)
          || read == '+'
          || read == '-'
          || read == 'e'
          || read == 'E'
          || read == '.') {
        builder.append(input.read());
      } else {
        break;
      }

      if (read == '.') {
        fractionalPart = true;
      }
    } while (true);

    try {
      Number number = new BigDecimal(builder.toString());
      if (fractionalPart) {
        return number.doubleValue();
      }
      return number.longValue();
    } catch (NumberFormatException e) {
      throw new JsonException("Unable to parse to a number: " + builder.toString() + ". " + input);
    }
  }

  public String nextString() {
    expect(JsonType.STRING);
    return readString();
  }

  public Instant nextInstant() {
    Long time = read(Long.class);
    return (null != time) ? Instant.ofEpochSecond(time) : null;
  }

  public boolean hasNext() {
    if (stack.isEmpty()) {
      throw new JsonException(
          "Unable to determine if an item has next when not in a container type. " + input);
    }

    skipWhitespace(input);
    if (input.peek() == ',') {
      input.read();
      return true;
    }

    JsonType type = peek();
    return type != JsonType.END_COLLECTION && type != JsonType.END_MAP;
  }

  public void beginArray() {
    expect(JsonType.START_COLLECTION);
    stack.addFirst(Container.COLLECTION);
    input.read();
  }

  public void endArray() {
    expect(JsonType.END_COLLECTION);
    Container expectation = stack.removeFirst();
    if (expectation != Container.COLLECTION) {
      // The only other thing we could be closing is a map
      throw new JsonException(
          "Attempt to close a JSON List, but a JSON Object was expected. " + input);
    }
    input.read();
  }

  public void beginObject() {
    expect(JsonType.START_MAP);
    stack.addFirst(Container.MAP_NAME);
    input.read();
  }

  public void endObject() {
    expect(JsonType.END_MAP);
    Container expectation = stack.removeFirst();
    if (expectation != Container.MAP_NAME) {
      // The only other thing we could be closing is a map
      throw new JsonException("Attempt to close a JSON Map, but not ready to. " + input);
    }
    input.read();
  }

  public void skipValue() {
    switch (peek()) {
      case BOOLEAN:
        nextBoolean();
        break;

      case NAME:
        nextName();
        break;

      case NULL:
        nextNull();
        break;

      case NUMBER:
        nextNumber();
        break;

      case START_COLLECTION:
        beginArray();
        while (hasNext()) {
          skipValue();
        }
        endArray();
        break;

      case START_MAP:
        beginObject();
        while (hasNext()) {
          nextName();
          skipValue();
        }
        endObject();
        break;

      case STRING:
        nextString();
        break;

      default:
        throw new JsonException("Cannot skip " + peek() + ". " + input);
    }
  }

  public <T> T read(Type type) {
    skipWhitespace(input);

    // Guard against reading an empty stream
    if (input.peek() == Input.EOF) {
      return null;
    }

    return coercer.coerce(this, type, setter);
  }

  private boolean isReadingName() {
    return stack.peekFirst() == Container.MAP_NAME;
  }

  private void expect(JsonType type) {
    if (peek() != type) {
      throw new JsonException(
          "Expected to read a " + type + " but instead have: " + peek() + ". " + input);
    }

    // Special map handling. Woo!
    Container top = stack.peekFirst();

    if (type == JsonType.NAME) {
      if (top == Container.MAP_NAME) {
        stack.removeFirst();
        stack.addFirst(Container.MAP_VALUE);
        return;
      } else if (top != null) {
        throw new JsonException("Unexpected attempt to read name. " + input);
      }

      return; // End of Name handling
    }

    // Handle the case where we're reading a value
    if (top == Container.MAP_VALUE) {
      stack.removeFirst();
      stack.addFirst(Container.MAP_NAME);
    }
  }

  private <X> X read(String toCompare, Function<String, X> mapper) {
    skipWhitespace(input);

    for (int i = 0; i < toCompare.length(); i++) {
      char read = input.read();
      if (read != toCompare.charAt(i)) {
        throw new JsonException(
            String.format(
                "Unable to read %s. Saw %s at position %d. %s", toCompare, read, i, input));
      }
    }

    return mapper.apply(toCompare);
  }

  private String readString() {
    input.read(); // Skip leading quote

    StringBuilder builder = new StringBuilder();
    char c;
    while (true) {
      c = input.read();
      switch (c) {
        case Input.EOF:
          throw new JsonException("Unterminated string: " + builder + ". " + input);
        case '"': // terminate string
          return builder.toString();
        case '\\': // quoted char
          readEscape(builder);
          break;
        default:
          builder.append(c);
      }
    }
  }

  private void readEscape(StringBuilder builder) {
    char read = input.read();

    // List from: https://tools.ietf.org/html/rfc7159.html#section-7
    switch (read) {
      case 'b':
        builder.append("\b");
        break;

      case 'f':
        builder.append("\f");
        break;

      case 'n':
        builder.append("\n");
        break;

      case 'r':
        builder.append("\r");
        break;

      case 't':
        builder.append("\t");
        break;

      case 'u': // Unicode digit. The next four characters count.
        int result = 0;
        int multiplier = 4096; // (16 * 16 * 16) as we start from the thousands and work to units.
        for (int i = 0; i < 4; i++) {
          char c = input.read();
          int digit = Character.digit(c, 16);
          if (digit == -1) {
            throw new JsonException(c + " is not a hexadecimal digit. " + input);
          }
          result += digit * multiplier;
          multiplier /= 16;
        }
        builder.append((char) result);
        break;

      case '/':
      case '\\':
      case '"':
        builder.append(read);
        break;

      default:
        throw new JsonException("Unexpected escape code: " + read + ". " + input);
    }
  }

  private void skipWhitespace(Input input) {
    while (input.peek() != Input.EOF && Character.isWhitespace(input.peek())) {
      input.read();
    }
  }

  private enum Container {
    COLLECTION,
    MAP_NAME,
    MAP_VALUE,
  }
}
