// 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 static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.openqa.selenium.json.Json.MAP_TYPE;
import static org.openqa.selenium.json.JsonType.BOOLEAN;
import static org.openqa.selenium.json.JsonType.END_COLLECTION;
import static org.openqa.selenium.json.JsonType.END_MAP;
import static org.openqa.selenium.json.JsonType.NAME;
import static org.openqa.selenium.json.JsonType.NULL;
import static org.openqa.selenium.json.JsonType.NUMBER;
import static org.openqa.selenium.json.JsonType.START_COLLECTION;
import static org.openqa.selenium.json.JsonType.START_MAP;
import static org.openqa.selenium.json.JsonType.STRING;
import static org.openqa.selenium.json.PropertySetting.BY_NAME;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.StringReader;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

@Tag("UnitTests")
class JsonInputTest {

  @Test
  void shouldParseBooleanValues() {
    try (JsonInput input = newInput("true")) {
      assertThat(input.peek()).isEqualTo(BOOLEAN);
      assertThat(input.nextBoolean()).isTrue();
    }

    try (JsonInput input = newInput("false")) {
      assertThat(input.peek()).isEqualTo(BOOLEAN);
      assertThat(input.nextBoolean()).isFalse();
    }
  }

  @Test
  void shouldParseNonDecimalNumbersAsLongs() {
    try (JsonInput input = newInput("42")) {
      assertThat(input.peek()).isEqualTo(NUMBER);
      assertThat(input.nextNumber()).isEqualTo(42L);
    }
  }

  @Test
  void shouldParseDecimalNumbersAsDoubles() {
    try (JsonInput input = newInput("42.0")) {
      assertThat(input.peek()).isEqualTo(NUMBER);
      assertThat((Double) input.nextNumber()).isEqualTo(42.0d);
    }
  }

  @Test
  void shouldHandleNullValues() {
    try (JsonInput input = newInput("null")) {
      assertThat(input.peek()).isEqualTo(NULL);
      assertThat(input.nextNull()).isNull();
    }
  }

  @Test
  void shouldBeAbleToReadAString() {
    try (JsonInput input = newInput("\"cheese\"")) {
      assertThat(input.peek()).isEqualTo(STRING);
      assertThat(input.nextString()).isEqualTo("cheese");
    }
  }

  @Test
  void shouldBeAbleToHandleAnUnterminatedString() {
    try (JsonInput input = newInput("\"cheese")) {
      assertThat(input.peek()).isEqualTo(STRING);
      assertThatExceptionOfType(JsonException.class)
          .isThrownBy(input::nextString)
          .withMessageStartingWith("Unterminated string");
    }
  }

  @Test
  void shouldBeAbleToReadTheEmptyString() {
    try (JsonInput input = newInput("\"\"")) {
      assertThat(input.peek()).isEqualTo(STRING);
      assertThat(input.nextString()).isEmpty();
    }
  }

  @Test
  void anEmptyArrayHasNoContents() {
    try (JsonInput input = newInput("[]")) {
      assertThat(input.peek()).isEqualTo(START_COLLECTION);
      input.beginArray();
      assertThat(input.hasNext()).isFalse();
      assertThat(input.peek()).isEqualTo(END_COLLECTION);
      input.endArray();
    }
  }

  @Test
  void anArrayWithASingleElementHasNextButOnlyOneValue() {
    try (JsonInput input = newInput("[ \"peas\"]")) {
      input.beginArray();
      assertThat(input.nextString()).isEqualTo("peas");
      input.endArray();
    }
  }

  @Test
  void anArrayWithMultipleElementsReturnsTrueFromHasNextMoreThanOnce() {
    try (JsonInput input = newInput("[\"brie\", \"cheddar\"]")) {
      input.beginArray();
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextString()).isEqualTo("brie");
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextString()).isEqualTo("cheddar");
      assertThat(input.hasNext()).isFalse();
      input.endArray();
    }
  }

  @Test
  void callingHasNextWhenNotInAnArrayOrMapIsAnError() {
    try (JsonInput input = newInput("\"cheese\"")) {
      assertThatThrownBy(input::hasNext)
          .isInstanceOf(JsonException.class)
          .hasMessageStartingWith(
              "Unable to determine if an item has next when not in a container type");
    }
  }

  @Test
  void anEmptyMapHasNoContents() {
    try (JsonInput input = newInput("{      }")) {
      assertThat(input.peek()).isEqualTo(START_MAP);
      input.beginObject();
      assertThat(input.hasNext()).isFalse();
      assertThat(input.peek()).isEqualTo(END_MAP);
      input.endObject();
    }
  }

  @Test
  void canReadAMapWithASingleEntry() {
    try (JsonInput input = newInput("{\"cheese\": \"feta\"}")) {
      input.beginObject();
      assertThat(input.hasNext()).isTrue();
      assertThat(input.peek()).isEqualTo(NAME);
      assertThat(input.nextName()).isEqualTo("cheese");
      assertThat(input.peek()).isEqualTo(STRING);
      assertThat(input.nextString()).isEqualTo("feta");
      assertThat(input.hasNext()).isFalse();
      input.endObject();
    }
  }

  @Test
  void canReadAMapWithManyEntries() {
    String json = "{'cheese': 'stilton', 'vegetable': 'peas', 'random': 42}".replace('\'', '"');
    try (JsonInput input = newInput(json)) {
      assertThat(input.peek()).isEqualTo(START_MAP);
      input.beginObject();
      assertThat(input.hasNext()).isTrue();
      assertThat(input.peek()).isEqualTo(NAME);
      assertThat(input.nextName()).isEqualTo("cheese");
      assertThat(input.nextString()).isEqualTo("stilton");
      assertThat(input.hasNext()).isTrue();
      assertThat(input.peek()).isEqualTo(NAME);
      assertThat(input.nextName()).isEqualTo("vegetable");
      assertThat(input.nextString()).isEqualTo("peas");
      assertThat(input.hasNext()).isTrue();
      assertThat(input.peek()).isEqualTo(NAME);
      assertThat(input.nextName()).isEqualTo("random");
      assertThat(input.nextNumber()).isEqualTo(42L);
      assertThat(input.hasNext()).isFalse();
      assertThat(input.peek()).isEqualTo(END_MAP);
      input.endObject();
    }
  }

  @Test
  void nestedMapIsFine() {
    try (JsonInput input = newInput("{\"map\": {\"child\": [\"hello\",\"world\"]}}")) {

      input.beginObject();
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextName()).isEqualTo("map");
      input.beginObject();
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextName()).isEqualTo("child");
      input.beginArray();
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextString()).isEqualTo("hello");
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextString()).isEqualTo("world");
      assertThat(input.hasNext()).isFalse();
      input.endArray();
      assertThat(input.hasNext()).isFalse();
      input.endObject();
      assertThat(input.hasNext()).isFalse();
      input.endObject();
    }
  }

  @Test
  void shouldDecodeUnicodeEscapesProperly() {
    String raw = "{\"text\": \"\\u003Chtml\"}";

    try (JsonInput in = new JsonInput(new StringReader(raw), new JsonTypeCoercer(), BY_NAME)) {
      Map<String, Object> map = in.read(MAP_TYPE);

      assertThat(map.get("text")).isEqualTo("<html");
    }
  }

  @Test
  void shouldCallFromJsonWithJsonInputParameter() {
    String raw = "{\"message\": \"Cheese!\"}";

    try (JsonInput in = new JsonInput(new StringReader(raw), new JsonTypeCoercer(), BY_NAME)) {
      HasFromJsonWithJsonInputParameter obj = in.read(HasFromJsonWithJsonInputParameter.class);

      assertThat(obj.getMessage()).isEqualTo("Cheese!");
    }
  }

  @Test
  void canReadListOfType() {
    String raw = " [ 1 , 2 , 3 , 4 ] ";

    try (JsonInput in = new JsonInput(new StringReader(raw), new JsonTypeCoercer(), BY_NAME)) {
      List<Integer> array = in.readArray(Integer.class);

      assertThat(array).containsExactly(1, 2, 3, 4);
    }
  }

  @Test
  void shouldBeAbleToReadDataLongerThanReadBuffer() {
    char[] chars = new char[] {'c', 'h', 'e', 's'};
    Random r = new Random();
    String raw =
        Stream.generate(() -> "" + chars[r.nextInt(4)]).limit(150).collect(Collectors.joining());
    try (JsonInput input = newInput("\"" + raw + "\"")) {
      assertThat(input.peek()).isEqualTo(STRING);
      assertThat(input.nextString()).isEqualTo(raw);
    }
  }

  @Test
  void shouldBeAbleToReadNonWellFormedDataLongerThanReadBuffer() {
    char[] chars = new char[] {'c', 'h', 'e', 's'};
    Random r = new Random();
    String raw =
        Stream.generate(() -> "" + chars[r.nextInt(4)]).limit(150).collect(Collectors.joining());
    try (JsonInput input = newInput("\"" + raw)) {
      assertThat(input.peek()).isEqualTo(STRING);
      assertThatExceptionOfType(JsonException.class)
          .isThrownBy(input::nextString)
          .withMessageStartingWith(
              String.format(
                  "Unterminated string: %s. Last 128 characters read: %s",
                  raw, raw.substring(raw.length() - 128)));
    }
  }

  @Test
  void shouldReadU_FFFF_AsALiteralCharacterAndNotEndOfInput() {
    // U+FFFF is a valid Unicode code unit that historically collided with the in-band EOF
    // sentinel and was mis-reported as an unterminated string. Build the strings from
    // char values rather than embedding literal U+FFFF so the test is independent of the
    // source file's byte encoding.
    char nonChar = (char) 0xFFFF;
    String literalPayload = "a" + nonChar + "b";

    try (JsonInput input = newInput("\"" + literalPayload + "\"")) {
      assertThat(input.nextString()).isEqualTo(literalPayload);
    }

    try (JsonInput input = newInput("\"\\uFFFF\"")) {
      assertThat(input.nextString()).isEqualTo(String.valueOf(nonChar));
    }
  }

  @Test
  void shouldRejectUnescapedControlCharactersInStrings() {
    // RFC 8259 §7: characters U+0000..U+001F MUST be escaped in JSON strings.
    // A literal newline / tab / etc. inside quotes is not valid JSON.
    try (JsonInput input = newInput("\"a\nb\"")) {
      assertThatExceptionOfType(JsonException.class)
          .isThrownBy(input::nextString)
          .withMessageStartingWith("Illegal unescaped control character");
    }

    // Escaped equivalents are still fine.
    try (JsonInput input = newInput("\"a\\nb\"")) {
      assertThat(input.nextString()).isEqualTo("a\nb");
    }
  }

  @Test
  void shouldRejectSpecInvalidNumbers() {
    // See RFC 8259 §6. Each of these was previously accepted by nextNumber().
    for (String bad :
        new String[] {
          "+5", // leading plus not allowed
          "01", // leading zero not allowed
          "007", // leading zero not allowed
          ".5", // no digit before decimal
          "5.", // no digit after decimal
          "1e", // exponent without digits
          "-", // sign without digits
        }) {
      try (JsonInput input = newInput(bad)) {
        assertThatExceptionOfType(JsonException.class)
            .describedAs("Input %s should be rejected", bad)
            .isThrownBy(input::nextNumber);
      }
    }
  }

  @Test
  void shouldStillAcceptSpecValidNumbers() {
    assertThat(parseNumber("0")).isEqualTo(0L);
    assertThat(parseNumber("-0")).isEqualTo(0L);
    assertThat(parseNumber("42")).isEqualTo(42L);
    assertThat(parseNumber("-17")).isEqualTo(-17L);
    assertThat(parseNumber("3.14")).isEqualTo(3.14d);
    assertThat(parseNumber("-2.5e10")).isEqualTo(-2.5e10d);
    assertThat(parseNumber("1E+2")).isEqualTo(100.0d);
    assertThat(parseNumber("0.0")).isEqualTo(0.0d);
  }

  @Test
  void shouldRejectDoubleOverflow() {
    try (JsonInput input = newInput("1e9999")) {
      assertThatExceptionOfType(JsonException.class)
          .isThrownBy(input::nextNumber)
          .withMessageContaining("out of range");
    }
  }

  private Number parseNumber(String raw) {
    try (JsonInput input = newInput(raw)) {
      return input.nextNumber();
    }
  }

  @Test
  void shouldReportEndOfInputAsEofNotAsAUnicodeReplacement() {
    // If the number ends prematurely, the diagnostic should say "<EOF>" rather than "''"
    // (which is a valid literal character elsewhere).
    try (JsonInput input = newInput("5.")) {
      assertThatExceptionOfType(JsonException.class)
          .isThrownBy(input::nextNumber)
          .withMessageContaining("<EOF>");
    }
  }

  @Test
  void shouldAcceptTrailingCommaInArray() {
    try (JsonInput input = newInput("[1,2,3,]")) {
      input.beginArray();
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextNumber()).isEqualTo(1L);
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextNumber()).isEqualTo(2L);
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextNumber()).isEqualTo(3L);
      assertThat(input.hasNext()).isFalse();
      input.endArray();
    }
  }

  @Test
  void shouldAcceptTrailingCommaInObject() {
    try (JsonInput input = newInput("{\"a\":1,}")) {
      input.beginObject();
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextName()).isEqualTo("a");
      assertThat(input.nextNumber()).isEqualTo(1L);
      assertThat(input.hasNext()).isFalse();
      input.endObject();
    }
  }

  @Test
  void shouldRejectMissingCommaBetweenArrayElements() {
    try (JsonInput input = newInput("[1 2]")) {
      input.beginArray();
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextNumber()).isEqualTo(1L);
      assertThatExceptionOfType(JsonException.class).isThrownBy(input::hasNext);
    }
  }

  @Test
  void shouldRejectLeadingCommaInArray() {
    try (JsonInput input = newInput("[,1]")) {
      input.beginArray();
      assertThatExceptionOfType(JsonException.class).isThrownBy(input::hasNext);
    }
  }

  @Test
  void shouldRejectMissingCommaBetweenObjectEntries() {
    try (JsonInput input = newInput("{\"a\":1 \"b\":2}")) {
      input.beginObject();
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextName()).isEqualTo("a");
      assertThat(input.nextNumber()).isEqualTo(1L);
      assertThatExceptionOfType(JsonException.class).isThrownBy(input::hasNext);
    }
  }

  @Test
  void shouldRejectLeadingCommaInObject() {
    try (JsonInput input = newInput("{,\"a\":1}")) {
      input.beginObject();
      assertThatExceptionOfType(JsonException.class).isThrownBy(input::hasNext);
    }
  }

  @Test
  void mismatchedCloseLeavesParserStateIntact() {
    // endObject() while inside an array must fail without popping the container stack,
    // so the parser still knows it is inside the array afterwards.
    try (JsonInput input = newInput("[1}")) {
      input.beginArray();
      assertThat(input.nextNumber()).isEqualTo(1L);
      assertThatExceptionOfType(JsonException.class)
          .isThrownBy(input::endObject)
          .withMessageStartingWith("Attempt to close a JSON Map");
      // Before the fix this threw "not in a container type" because the array's
      // stack entry had already been popped.
      assertThat(input.hasNext()).isFalse();
    }
  }

  @Test
  void hasNextIsIdempotentBetweenElementsInArray() {
    // Iterator-style probing (peek/hasNext repeatedly before reading) must not falsely fail
    // once hasNext() has already consumed the comma before the next element.
    try (JsonInput input = newInput("[1,2]")) {
      input.beginArray();
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextNumber()).isEqualTo(1L);
      assertThat(input.hasNext()).isTrue();
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextNumber()).isEqualTo(2L);
      assertThat(input.hasNext()).isFalse();
      assertThat(input.hasNext()).isFalse();
      input.endArray();
    }
  }

  @Test
  void hasNextIsIdempotentBetweenEntriesInObject() {
    try (JsonInput input = newInput("{\"a\":1,\"b\":2}")) {
      input.beginObject();
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextName()).isEqualTo("a");
      assertThat(input.nextNumber()).isEqualTo(1L);
      assertThat(input.hasNext()).isTrue();
      assertThat(input.hasNext()).isTrue();
      assertThat(input.nextName()).isEqualTo("b");
      assertThat(input.nextNumber()).isEqualTo(2L);
      assertThat(input.hasNext()).isFalse();
      input.endObject();
    }
  }

  @Test
  void nullInputsShouldCoerceAsNullValues() throws IOException {
    try (InputStream is = new ByteArrayInputStream(new byte[0]);
        Reader reader = new InputStreamReader(is, UTF_8);
        JsonInput input = new Json().newInput(reader)) {

      Object value = input.read(MAP_TYPE);

      assertThat(value).isNull();
    }
  }

  @Test
  void emptyStringsWithNoJsonValuesComeBackAsNull() throws IOException {
    try (InputStream is = new ByteArrayInputStream("     ".getBytes(UTF_8));
        Reader reader = new InputStreamReader(is, UTF_8);
        JsonInput input = new Json().newInput(reader)) {

      Object value = input.read(String.class);

      assertThat(value).isNull();
    }
  }

  private JsonInput newInput(String raw) {
    StringReader reader = new StringReader(raw);
    return new JsonInput(reader, new JsonTypeCoercer(), BY_NAME);
  }

  public static class HasFromJsonWithJsonInputParameter {

    private final String message;

    public HasFromJsonWithJsonInputParameter(String message) {
      this.message = message;
    }

    public String getMessage() {
      return message;
    }

    @SuppressWarnings("unused")
    private static HasFromJsonWithJsonInputParameter fromJson(JsonInput input) {
      input.beginObject();
      input.nextName();
      String message = input.nextString();
      input.endObject();

      return new HasFromJsonWithJsonInputParameter(message);
    }
  }
}
