// 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.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() {
    JsonInput input = newInput("true");
    assertThat(input.peek()).isEqualTo(BOOLEAN);
    assertThat(input.nextBoolean()).isTrue();

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

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

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

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

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

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

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

  @Test
  void anEmptyArrayHasNoContents() {
    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() {
    JsonInput input = newInput("[ \"peas\"]");
    input.beginArray();
    assertThat(input.nextString()).isEqualTo("peas");
    input.endArray();
  }

  @Test
  void anArrayWithMultipleElementsReturnsTrueFromHasNextMoreThanOnce() {
    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() {
    JsonInput input = newInput("\"cheese\"");
    assertThatExceptionOfType(JsonException.class).isThrownBy(input::hasNext);
  }

  @Test
  void anEmptyMapHasNoContents() {
    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() {
    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() {
    JsonInput input =
        newInput(
            "{" + "\"cheese\": \"stilton\"," + "\"vegetable\": \"peas\"," + "\"random\": 42" + "}");

    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() {
    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).isEqualTo(List.of(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());
    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());
    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 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;
    }

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

      return new HasFromJsonWithJsonInputParameter(message);
    }
  }
}
